Files
Netcatty/components/ai/ChatInput.test.tsx
T
8b5b0c4416 fix(ai): keep dropped AI chats recoverable with dangling marking + /rebind; reuse workspace id on merge (#4019)
* fix(ai): keep dropped AI chats recoverable with dangling marking and /rebind

Closing a member terminal or dissolving a workspace used to clear the
chat's active pointer, making it invisible (scope rank 0) and
unrecoverable from the UI even though the record survived in storage.

- orphan cleanup now marks dropped chats `scopeDangling: true` and keeps
  record + active pointer intact; the flag clears when the target
  returns or the chat is rebound
- `/chats` lists dangling chats with a numbered inline affordance and
  hint line
- new `/rebind <n>` panel action rewrites `scope` to the panel's current
  scope, clears the marker and restores the active pointer
- i18n keys added for en / zh-CN / zh-TW / es / ru

Stacked on #4018. Refs #4015 (R2).

* fix(workspace): reuse existing workspace id when merging tabs with a workspace

createWorkspaceFromSessions silently no-opped when either merged tab
already belonged to a workspace (e.g. dragging a workspace pane onto a
standalone terminal), and the older dissolve->recreate behavior
regenerated the id that workspace-scoped AI chats bind to.

When one of the merged tabs has a workspace, the orphan tab now joins
that existing workspace (pane inserted next to the anchor session, tab
slot collapsed), so workspace-scoped chat bindings survive. Merging two
standalone tabs still creates a fresh workspace.

Stacked on #4018. Refs #4015 (R3).

* fix(ai): persist cleaned active map on dangling chat rebind

rebindSessionScope was updating React state to drop stale old-scope
pointers, then rebuilding localStorage from the pre-rebind snapshot and
resurrecting them. Persist the same cleaned map via rebindActiveSessionMap.

* fix(ai): persist rebind map inside setState; wire backup actions on terminal panel

- Write cleaned active-session map from inside the rebind updater so a
  deferred React setState cannot skip snapshot/localStorage persistence.
- Pass exportSessionsBackup/importSessionsBackup through TerminalLayerSupport
  and include them in AI_CHAT_SIDE_PANEL_AI_STATE_KEYS so history-drawer
  backup buttons work on the normal terminal path.

* fix(ai): guard backup restore races and use app-wide live targets for /chats

- Skip restore when in-memory sessions already exist; apply snapshot,
  storage write, and AI state emit atomically inside the empty-state path
  so peer useAIState instances see the restore.
- Thread validAIScopeTargetIds into AIChatSidePanel as liveScopeTargetIds
  so /chats does not mark chats bound to other open terminals as dangling.

* fix(ai): open rebound chat in panel view and refresh /chats listing

- rebindSessionScope also sets the panel view to the rebound session so an
  existing draft/other chat does not keep hiding the recovery.
- After /rebind, rewrite the stored listing instead of rebuilding from the
  stale render so the chat is no longer offered as dangling.

* fix(workspace): invert join split when dragged pane owns workspace; refresh /chats titles

- When merging a workspace-owned dragged pane onto a standalone tab, the
  orphan is inserted — invert the drop position so the previewed side matches.
- Compare workspaces in AIChatSidePanel memo so /chats picks up renames.

* fix(ai): validate live sessions array on boot; mark imported orphans dangling

- Reject parseable non-array store values (e.g. {}) via readLiveSessionsArray
  so boot restore is not blocked by a corrupt-but-truthy value.
- After manual import, re-run orphan cleanup with the last known live target
  set so chats bound to closed scopes become dangling and /rebind-able.

* fix(ai): merge imported sessions before orphan cleanup

Apply import merge to the snapshot/ref synchronously, then run dangling
marking, so a deferred setState cannot let cleanup rewrite over the import.

* fix(ai): mark restored/imported orphans dangling even with no live targets

Restore and import do not change live terminals/workspaces, so the orphan
cleanup effect will not re-run. Always call cleanup against the last known
live target set after those paths — an empty set is a valid observed state
(nothing mounted), not an unavailable snapshot — so chats bound to missing
scopes become dangling and /chats can offer /rebind.

* fix(ai): apply backup restore synchronously before dangling cleanup

Do not gate restore + orphan marking on a setState updater having run.
A deferred updater left didRestore false, skipped cleanup, and could leave
restored chats with missing scopes unmarked and unrebindable from /chats.

* fix(ai): complete /rebind from a partial slash picker selection

Selecting Rebind after typing `/re` left the composer unchanged, so a
following number became `/re 1` and could be sent to the agent. Replace the
slash query with `/rebind ` the same way Rename does.

* fix(ai): replace hostIds on rebind; skip non-array backup generations

- Rebind builds a fresh destination scope (including hostIds from the
  panel) instead of spreading the old scope, so history matching follows
  the new terminal/workspace host.
- readLatestGeneration treats valid JSON that is not a session array as
  unusable and continues to older generations, matching corrupt skips.

* fix(ai): revalidate dangling chat before /rebind from stale /chats listing

Skip rebind when the cached entry is deleted or already bound to a live
scope; drop stale listing entries so a second panel cannot steal the chat.

* fix(ai): reject malformed /rebind numbers

Require the entire trimmed argument to be digits so /rebind 1foo and
/rebind 1.5 show usage instead of silently targeting index 1.

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-27 20:26:21 -07:00

409 lines
16 KiB
TypeScript

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, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } 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('selecting rename completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithRename('/ren', { start: 0, end: 4 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('/', { start: 0, end: 1 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('before /ren after', { start: 7, end: 11 }), 'before /rename after');
assert.equal(replaceSlashQueryWithRename('old text', null), '/rename ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rename')"), source.indexOf("commitComposerText('');", source.indexOf("if (command === 'rename')")));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithRename\(readComposerText\(\), slashRange\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('selecting rebind completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('slash picker system commands clear the local composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
// /stop falls through to the shared clear; /rename, /chats and /rebind have
// their own branches above it (rename/rebind keep the composer so the user
// can type an argument).
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
});
test('/rebind routes its argument to onRebindChat as a panel action', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
});
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('ChatInput intercepts /rename and /chats as panel actions before onSend', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
// The interception happens in handleSubmit's system-command branch, which
// returns before onSend() — the commands never reach the agent.
assert.match(source, /systemCommand === 'rename'/);
assert.match(source, /systemCommand === 'chats'/);
assert.match(source, /onRenameSession\?\.\(getRenameSlashCommandTitle\(submittedText\)\)/);
assert.match(source, /onListChats\?\.\(\)/);
// Both commands clear the composer instead of falling through to onSend.
const renameBranchStart = source.indexOf("systemCommand === 'rename'");
const renameBranchEnd = source.indexOf('if (isStreaming && canSteer)', renameBranchStart);
const renameBranch = source.slice(renameBranchStart, renameBranchEnd);
assert.match(renameBranch, /commitComposerText\(''\)/);
assert.doesNotMatch(renameBranch, /onSend\(\)/);
// The slash picker registers the built-ins and keeps /rename text editable.
assert.match(source, /command === 'rename'/);
assert.match(source, /command === 'chats'/);
});
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\}/);
});