import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import ChatInput from './ChatInput'; import { CHAT_INPUT_MAX_HEIGHT, CHAT_INPUT_MIN_HEIGHT, resolveChatInputAriaHeight, resolveChatInputMaxHeight, resolveChatInputResizeHeight, resolveVisibleChatInputHeight, resolveVisibleChatInputMaxHeight, } from './chatInputResize'; import { TooltipProvider } from '../ui/tooltip'; test('clamps composer dragging to the usable pane height', () => { assert.equal(resolveChatInputMaxHeight(900), CHAT_INPUT_MAX_HEIGHT); assert.equal(resolveChatInputMaxHeight(180), CHAT_INPUT_MIN_HEIGHT); assert.equal(resolveChatInputResizeHeight(128, 500, 420, 360), 208); assert.equal(resolveChatInputResizeHeight(128, 500, 700, 360), CHAT_INPUT_MIN_HEIGHT); assert.equal(resolveChatInputResizeHeight(300, 500, 300, 360), 360); }); test('keeps the requested composer height while the pane is temporarily hidden or constrained', () => { assert.equal(resolveVisibleChatInputMaxHeight(0), null); assert.equal(resolveVisibleChatInputMaxHeight(Number.NaN), null); assert.equal(resolveVisibleChatInputHeight(360, 220), 220); assert.equal(resolveVisibleChatInputHeight(360, 400), 360); assert.equal(resolveVisibleChatInputHeight(null, 400), null); }); test('reports an accessible composer height inside the available range', () => { assert.equal(resolveChatInputAriaHeight(null, CHAT_INPUT_MIN_HEIGHT), CHAT_INPUT_MIN_HEIGHT); assert.equal(resolveChatInputAriaHeight(360, 220), 220); assert.equal(resolveChatInputAriaHeight(160, 220), 160); }); test('renders an accessible composer resize handle', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} /> , ); assert.match(html, /role="separator"/); assert.match(html, /aria-orientation="horizontal"/); assert.match(html, /aria-label="ai\.chat\.resizeInput"/); assert.match(html, /cursor-ns-resize/); }); test('expanded composer grows the text area while keeping controls at the bottom', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /data-section="ai-chat-input-body"/); assert.match(source, /composerHeight != null \? 'flex min-h-0 flex-1 flex-col'/); assert.match(source, /data-section="ai-chat-input-footer"/); assert.match(source, /className="shrink-0/); assert.doesNotMatch(source, / { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /parked = false/); assert.match(source, /if \(parked\) closeAllMenus\(\)/); assert.match(source, /becameParked/); }); test('first keystrokes stay local so IME and Chromium spellcheck cannot stall the composer', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /spellCheck=\{false\}/); assert.match(source, /autoCorrect="off"/); assert.match(source, /autoCapitalize="off"/); assert.match(source, /nativeEvent\.isComposing/); assert.match(source, /onCompositionEnd=/); assert.match(source, /onBlur=\{\(\) => commitComposerText\(readComposerText\(\)\)\}/); assert.match(source, /onChangeRef\.current\(textareaRef\.current\?\.value \?\? composerTextRef\.current\)/); assert.doesNotMatch(source, /onSend\(\);\s*commitComposerText\(''\);/); assert.match(source, /defaultValue=\{value\}/); assert.match(source, /field-sizing-fixed/); assert.doesNotMatch(source, /value=\{composerText\}/); assert.match(source, /createComposerHasTextStore/); assert.match(source, /ComposerSendUi/); assert.match(source, /chatInputPropsAreEqual/); }); test('composer resizing also ends when pointer capture is unexpectedly lost', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /resizeStartRef\.current = null;[\s\S]*releasePointerCapture/); assert.match(source, /onLostPointerCapture=\{handleComposerResizeEnd\}/); }); test('virtualizes the host mention list without changing its option contract', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /VariableSizeVirtualList/); assert.match(source, /ref=\{atMentionListRef\}/); assert.match(source, /aria-activedescendant=\{hosts\[activeMenuIndex\] \? `at-mention-/); assert.match(source, /onMouseEnter=\{\(\) => setActiveMenuIndex\(idx\)\}/); assert.match(source, /onClick=\{\(\) => handleSelectAtMention\(host\)\}/); assert.match(source, /max-h-\[280px\]/); }); test('does not render a standalone slash command toolbar button', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} isStreaming={false} disabled={false} agentName="Catty Agent" quickMessages={[{ id: 'qm-1', slug: 'hello', name: 'Hello', description: 'Greeting', content: 'Say hello', }]} /> , ); assert.match(html, /textarea/); assert.doesNotMatch(html, /aria-label="ai\.chat\.slashCommands"/); }); test('renders separate steer and stop actions for a running Codex App Server turn', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} onSteer={() => {}} onStop={() => {}} isStreaming canSteer lockTurnConfiguration disabled={false} agentName="Codex" modelPresets={[{ id: 'gpt-test', name: 'GPT Test' }]} selectedModelId="gpt-test" onModelSelect={() => {}} /> , ); assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/); assert.match(html, /placeholder="ai\.codex\.steer\.placeholder"/); assert.match(html, /aria-label="Stop"/); assert.match(html, /disabled=""[^>]*aria-label="ai\.chat\.selectModel"/); }); test('allows terminal-selection-only steering submissions', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} onSteer={() => {}} onStop={() => {}} isStreaming canSteer disabled={false} agentName="Codex" files={[{ id: 'terminal-selection', filename: 'terminal-selection.txt', dataUrl: 'data:text/plain;base64,dGVzdA==', base64Data: 'dGVzdA==', mediaType: 'text/plain', terminalSelection: true, lineCount: 1, }]} /> , ); assert.match(html, /]*data-allow-empty-submit="true"/); assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/); assert.doesNotMatch( html, /]*disabled=""[^>]*aria-label="ai\.codex\.steer\.addInstruction"/, ); }); test('renders the Catty context usage ring after the model chip', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} agentName="Catty Agent" contextUsage={{ sessionId: 'session-1', inputTokens: 64_000, contextWindow: 128_000, estimated: true, }} /> , ); assert.match(html, /role="progressbar"/); assert.match(html, /stroke-dasharray=/); assert.match(html, /stroke-dashoffset=/); assert.match(html, /class="h-4 w-4"/); assert.doesNotMatch(html, /text-\[7px\]/); assert.match(html, /aria-valuenow="50"/); }); test('slash picker system commands clear the local composer', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s); }); test('keeps thinking on a separate chip instead of mixing it into the model list', () => { const html = renderToStaticMarkup( {}} onSend={() => {}} agentName="Codex" modelPresets={[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], }]} selectedModelId="gpt-5.5/high" onModelSelect={() => {}} /> , ); assert.match(html, /aria-label="ai\.chat\.selectModel"/); assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/); assert.match(html, />High { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /ComposerModelPicker/); assert.match(source, /ComposerThinkingChip/); assert.match(source, /cattyReasoningLevelsForSelection/); assert.match(source, /if \(!thinkingLevel\) return;/); assert.doesNotMatch(source, /showThinkingLevels/); }); test('Catty thinking chip is hidden for OpenAI models that reject reasoningEffort', () => { const openai = { id: 'p1', providerId: 'openai' as const, name: 'OpenAI', enabled: true, }; const gpt4o = renderToStaticMarkup( {}} onSend={() => {}} agentName="Catty Agent" thinkingLevel="high" onThinkingLevelChange={() => {}} providerSwitcher={{ providers: [openai], selectedProviderId: 'p1', selectedModelId: 'gpt-4o', onSelect: () => {}, }} /> , ); assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/); const gpt51Chat = renderToStaticMarkup( {}} onSend={() => {}} agentName="Catty Agent" thinkingLevel="high" onThinkingLevelChange={() => {}} providerSwitcher={{ providers: [openai], selectedProviderId: 'p1', selectedModelId: 'gpt-5.1-chat-latest', onSelect: () => {}, }} /> , ); assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/); const gpt55 = renderToStaticMarkup( {}} onSend={() => {}} agentName="Catty Agent" thinkingLevel="high" onThinkingLevelChange={() => {}} providerSwitcher={{ providers: [openai], selectedProviderId: 'p1', selectedModelId: 'gpt-5.5', onSelect: () => {}, }} /> , ); assert.match(gpt55, /aria-label="ai\.chat\.thinkingLevel"/); }); test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /getSystemSlashCommand/); assert.match(source, /systemCommand === 'compact'/); assert.match(source, /canCompact/); assert.match(source, /onCompact\?\.\(\)/); assert.match(source, /command\.slug !== 'compact' \|\| canCompact/); }); test('renders a Mention Note picker entry with a searchable note list', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /openInputPanelMenu\('noteMention'\)/); assert.match(source, /ai\.chat\.menuMentionNote/); assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/); assert.match(source, /createVaultNoteSearchIndex\(notes\)/); assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/); assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/); }); test('renders vault note attachment chips with the note title', () => { const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8'); assert.match(source, /file\.vaultNoteId \? \(/); assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/); });