[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
352
components/ai/ChatInput.test.tsx
Normal file
352
components/ai/ChatInput.test.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput value="" onChange={() => {}} onSend={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
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, /<Expand/);
|
||||
assert.doesNotMatch(source, /setExpanded/);
|
||||
});
|
||||
|
||||
test('parked composer closes body-portaled menus', () => {
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
isStreaming={false}
|
||||
disabled={false}
|
||||
agentName="Catty Agent"
|
||||
quickMessages={[{
|
||||
id: 'qm-1',
|
||||
slug: 'hello',
|
||||
name: 'Hello',
|
||||
description: 'Greeting',
|
||||
content: 'Say hello',
|
||||
}]}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value="change direction"
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
onSteer={() => {}}
|
||||
onStop={() => {}}
|
||||
isStreaming
|
||||
canSteer
|
||||
lockTurnConfiguration
|
||||
disabled={false}
|
||||
agentName="Codex"
|
||||
modelPresets={[{ id: 'gpt-test', name: 'GPT Test' }]}
|
||||
selectedModelId="gpt-test"
|
||||
onModelSelect={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
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,
|
||||
}]}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /<form[^>]*data-allow-empty-submit="true"/);
|
||||
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/<button[^>]*disabled=""[^>]*aria-label="ai\.codex\.steer\.addInstruction"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the Catty context usage ring after the model chip', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
contextUsage={{
|
||||
sessionId: 'session-1',
|
||||
inputTokens: 64_000,
|
||||
contextWindow: 128_000,
|
||||
estimated: true,
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Codex"
|
||||
modelPresets={[{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
thinkingLevels: ['low', 'medium', 'high'],
|
||||
}]}
|
||||
selectedModelId="gpt-5.5/high"
|
||||
onModelSelect={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="ai\.chat\.selectModel"/);
|
||||
assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
assert.match(html, />High</);
|
||||
assert.doesNotMatch(html, /GPT-5\.5 \/ High/);
|
||||
});
|
||||
|
||||
test('Catty composer exposes a provider switcher without a mixed thinking submenu', () => {
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-4o',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt51Chat = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.1-chat-latest',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt55 = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.5',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
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\}/);
|
||||
});
|
||||
Reference in New Issue
Block a user