* 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>
409 lines
16 KiB
TypeScript
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\}/);
|
|
});
|