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
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import React from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
|
|
import { I18nProvider } from '../application/i18n/I18nProvider.tsx';
|
|
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
|
import { TooltipProvider } from './ui/tooltip.tsx';
|
|
import {
|
|
aiChatSidePanelPropsAreEqual,
|
|
AIChatSidePanel,
|
|
hasAIChatSidePanelRetainedContent,
|
|
shouldKeepAIChatSidePanelMounted,
|
|
} from './AIChatSidePanel.tsx';
|
|
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
|
|
|
|
type LocalStorageMock = {
|
|
clear(): void;
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
};
|
|
|
|
function installLocalStorage(): LocalStorageMock {
|
|
const store = new Map<string, string>();
|
|
const localStorage: LocalStorageMock = {
|
|
clear() {
|
|
store.clear();
|
|
},
|
|
getItem(key: string) {
|
|
return store.has(key) ? store.get(key)! : null;
|
|
},
|
|
setItem(key: string, value: string) {
|
|
store.set(key, String(value));
|
|
},
|
|
removeItem(key: string) {
|
|
store.delete(key);
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
value: localStorage,
|
|
configurable: true,
|
|
});
|
|
return localStorage;
|
|
}
|
|
|
|
const localStorage = installLocalStorage();
|
|
|
|
test.beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
|
|
text: '',
|
|
agentId: 'catty',
|
|
attachments: [],
|
|
selectedUserSkillSlugs: [],
|
|
updatedAt: 1,
|
|
...overrides,
|
|
});
|
|
|
|
const session = (overrides: Partial<AISession> = {}): AISession => ({
|
|
id: 'session-1',
|
|
title: 'Session',
|
|
agentId: 'catty',
|
|
scope: { type: 'terminal', targetId: 'terminal-1' },
|
|
messages: [],
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...overrides,
|
|
});
|
|
|
|
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
|
|
sessions: [],
|
|
activeSessionIdMap: {},
|
|
draftsByScope: {},
|
|
panelViewByScope: {},
|
|
setActiveSessionId: () => undefined,
|
|
ensureDraftForScope: () => undefined,
|
|
updateDraft: () => undefined,
|
|
showDraftView: () => undefined,
|
|
showSessionView: () => undefined,
|
|
clearDraftForScope: () => undefined,
|
|
addDraftFiles: async () => undefined,
|
|
removeDraftFile: () => undefined,
|
|
createSession: () => session(),
|
|
deleteSession: () => undefined,
|
|
updateSessionTitle: () => undefined,
|
|
updateSessionExternalSessionId: () => undefined,
|
|
addMessageToSession: () => undefined,
|
|
updateLastMessage: () => undefined,
|
|
updateMessageById: () => undefined,
|
|
persistContextCompaction: () => undefined,
|
|
providers: [],
|
|
activeProviderId: '',
|
|
activeModelId: '',
|
|
defaultAgentId: 'catty',
|
|
toolIntegrationMode: 'mcp',
|
|
externalAgents: [],
|
|
agentModelMap: {},
|
|
setAgentModel: () => undefined,
|
|
agentProviderMap: {},
|
|
setAgentProvider: () => undefined,
|
|
agentThinkingMap: {},
|
|
setAgentThinking: () => undefined,
|
|
globalPermissionMode: 'auto',
|
|
scopeType: 'terminal',
|
|
scopeTargetId: 'terminal-1',
|
|
isVisible: false,
|
|
...overrides,
|
|
});
|
|
|
|
test('send preflight aborts after the panel unmounts or the scope changes', () => {
|
|
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
|
assert.match(source, /sendEpochRef/);
|
|
assert.match(source, /if \(isSendStale\(\)\) return;/);
|
|
});
|
|
|
|
test('send preflight locks header agent and new-chat controls', () => {
|
|
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
|
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
|
assert.match(panel, /sending=\{isSending\}/);
|
|
assert.match(content, /disabled=\{sending\}/);
|
|
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
|
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
|
});
|
|
|
|
test('first send awaits provider sync instead of idle-deferring it', () => {
|
|
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
|
assert.match(source, /await sendBridge\.aiSyncProviders\(providers\)/);
|
|
assert.match(source, /await sendBridge\.aiSyncWebSearch\(/);
|
|
assert.doesNotMatch(source, /scheduleWhenAiComposerIdle\(\(\) => \{\s*void bridge\.aiSyncProviders/);
|
|
});
|
|
|
|
test('send and new chat discard pending composer text', () => {
|
|
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
|
assert.match(source, /discardPendingComposerText/);
|
|
assert.match(source, /if \(!options\?\.keepPendingText\) discardPendingComposerText\(\)/);
|
|
});
|
|
|
|
test('text-only draft writes do not emit AI state changes', () => {
|
|
const source = readFileSync(new URL('../application/state/useAIState.ts', import.meta.url), 'utf8');
|
|
assert.match(source, /draftsByScopeEqualIgnoringAllComposerText/);
|
|
assert.match(source, /if \(textOnly\) return;/);
|
|
});
|
|
|
|
test('first composer keystroke does not create a store draft synchronously', () => {
|
|
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
|
const setter = source.slice(
|
|
source.indexOf('const setInputValue = useCallback'),
|
|
source.indexOf('const addFiles = useCallback'),
|
|
);
|
|
assert.match(setter, /pendingComposerTextRef\.current = value/);
|
|
assert.doesNotMatch(setter, /enterScopeDraftMode/);
|
|
assert.doesNotMatch(setter, /setTimeout/);
|
|
});
|
|
|
|
test('hidden empty AI side panel can release its subtree', () => {
|
|
const props = baseProps();
|
|
|
|
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
|
|
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
|
|
});
|
|
|
|
test('hidden AI side panel is retained when it has draft text', () => {
|
|
const props = baseProps({
|
|
draftsByScope: {
|
|
'terminal:terminal-1': draft({ text: 'hello' }),
|
|
},
|
|
});
|
|
|
|
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
|
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
|
});
|
|
|
|
test('hidden AI side panel is retained when it has session messages', () => {
|
|
const props = baseProps({
|
|
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
|
sessions: [
|
|
session({
|
|
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
|
}),
|
|
],
|
|
});
|
|
|
|
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
|
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
|
});
|
|
|
|
test('visible AI side panel is always mounted even when empty', () => {
|
|
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
|
});
|
|
|
|
test('visible empty draft renders the input immediately without preparing state', () => {
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(
|
|
I18nProvider,
|
|
{ locale: 'en' },
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(AIChatSidePanel, baseProps({
|
|
isVisible: true,
|
|
sessions: [session({ id: 'session-history' })],
|
|
draftsByScope: {
|
|
'terminal:terminal-1': draft(),
|
|
},
|
|
panelViewByScope: {
|
|
'terminal:terminal-1': { mode: 'draft' },
|
|
},
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(markup, /textarea/);
|
|
assert.doesNotMatch(markup, /data-section="ai-chat-panel-preparing"/);
|
|
});
|
|
|
|
test('AI side panel re-renders when retained content becomes visible again', () => {
|
|
const hiddenProps = baseProps({
|
|
isVisible: false,
|
|
draftsByScope: {
|
|
'terminal:terminal-1': draft({ text: 'hello' }),
|
|
},
|
|
});
|
|
|
|
assert.equal(aiChatSidePanelPropsAreEqual(
|
|
hiddenProps,
|
|
{ ...hiddenProps, isVisible: true },
|
|
), false);
|
|
});
|
|
|
|
test('hidden retained AI side panel keeps the composer and skips message content', () => {
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(
|
|
I18nProvider,
|
|
{ locale: 'en' },
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(AIChatSidePanel, baseProps({
|
|
isVisible: false,
|
|
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
|
sessions: [
|
|
session({
|
|
messages: [
|
|
{ id: 'm1', role: 'user', content: 'hidden-user-message', timestamp: 1 },
|
|
{ id: 'm2', role: 'assistant', content: 'hidden-assistant-message', timestamp: 2 },
|
|
],
|
|
}),
|
|
],
|
|
draftsByScope: {
|
|
'terminal:terminal-1': draft({ text: 'draft still retained' }),
|
|
},
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(markup, /data-section="ai-chat-panel-retained"/);
|
|
assert.match(markup, /inert/);
|
|
assert.match(markup, /aria-hidden/);
|
|
assert.match(markup, /textarea/);
|
|
assert.doesNotMatch(markup, /hidden-user-message/);
|
|
assert.doesNotMatch(markup, /hidden-assistant-message/);
|
|
});
|
|
|
|
test('AI side panel re-renders when command timeout changes', () => {
|
|
const props = baseProps({
|
|
isVisible: true,
|
|
commandTimeout: 60,
|
|
});
|
|
|
|
assert.equal(aiChatSidePanelPropsAreEqual(
|
|
props,
|
|
{ ...props, commandTimeout: 86_400 },
|
|
), false);
|
|
});
|
|
|
|
test('AI side panel re-renders when response wait time changes', () => {
|
|
const props = baseProps({
|
|
isVisible: true,
|
|
responseIdleTimeout: 120,
|
|
});
|
|
|
|
assert.equal(aiChatSidePanelPropsAreEqual(
|
|
props,
|
|
{ ...props, responseIdleTimeout: 600 },
|
|
), false);
|
|
});
|
|
|
|
test('AI side panel skips re-render when only a sibling scope session object changes', () => {
|
|
const own = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
|
const siblingA = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
|
const siblingB = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
|
const prev = baseProps({
|
|
isVisible: true,
|
|
scopeType: 'terminal',
|
|
scopeTargetId: 'terminal-1',
|
|
sessions: [own, siblingA],
|
|
});
|
|
const next = {
|
|
...prev,
|
|
sessions: [own, siblingB],
|
|
};
|
|
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
|
|
|
|
const ownStreamed = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
|
assert.equal(
|
|
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [ownStreamed, siblingA] }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('AI side panel skips re-render when only the composer draft text changes', () => {
|
|
const empty = draft({ text: '' });
|
|
const prev = baseProps({
|
|
isVisible: true,
|
|
scopeType: 'terminal',
|
|
scopeTargetId: 'terminal-1',
|
|
draftsByScope: {
|
|
'terminal:terminal-1': empty,
|
|
},
|
|
});
|
|
const next = {
|
|
...prev,
|
|
draftsByScope: {
|
|
'terminal:terminal-1': { ...empty, text: '你好', updatedAt: 2 },
|
|
},
|
|
};
|
|
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
|
|
});
|
|
|
|
test('workspace AI panel memo follows visible inherited session, not nonmember terminal maps', () => {
|
|
const visibleMemberChat = session({
|
|
id: 'chat-visible',
|
|
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
|
});
|
|
const hiddenFocusedChat = session({
|
|
id: 'chat-hidden',
|
|
scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] },
|
|
});
|
|
const prev = baseProps({
|
|
isVisible: true,
|
|
scopeType: 'workspace',
|
|
scopeTargetId: 'ws-1',
|
|
focusedSessionId: 'terminal-a',
|
|
terminalSessions: [
|
|
{
|
|
sessionId: 'terminal-a',
|
|
hostId: 'host-a',
|
|
hostname: 'a.example',
|
|
label: 'A',
|
|
connected: true,
|
|
},
|
|
{
|
|
sessionId: 'terminal-b',
|
|
hostId: 'host-b',
|
|
hostname: 'b.example',
|
|
label: 'B',
|
|
connected: true,
|
|
},
|
|
],
|
|
activeSessionIdMap: {
|
|
'terminal:terminal-outside': 'chat-hidden',
|
|
'terminal:terminal-b': 'chat-visible',
|
|
},
|
|
sessions: [visibleMemberChat, hiddenFocusedChat],
|
|
});
|
|
const streamedVisible = session({
|
|
id: 'chat-visible',
|
|
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
|
messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }],
|
|
});
|
|
|
|
assert.equal(
|
|
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('merged AI panel displays a member chat resumed from an older terminal', () => {
|
|
const resumed = session({
|
|
id: 'resumed-chat',
|
|
scope: { type: 'terminal', targetId: 'closed-terminal', hostIds: ['host-a'] },
|
|
messages: [{ id: 'm1', role: 'user', content: 'keep-resumed-conversation', timestamp: 1 }],
|
|
});
|
|
const props = baseProps({
|
|
isVisible: true,
|
|
scopeType: 'workspace',
|
|
scopeTargetId: 'merged',
|
|
focusedSessionId: 'terminal-a',
|
|
scopeHostIds: ['host-a'],
|
|
terminalSessions: [{ sessionId: 'terminal-a', hostId: 'host-a', hostname: 'a', label: 'A', connected: true }],
|
|
sessions: [resumed],
|
|
activeSessionIdMap: { 'terminal:terminal-a': resumed.id },
|
|
});
|
|
const render = (panelProps: AIChatSidePanelProps) => renderToStaticMarkup(
|
|
React.createElement(I18nProvider, { locale: 'en' },
|
|
React.createElement(TooltipProvider, null,
|
|
React.createElement(AIChatSidePanel, panelProps))),
|
|
);
|
|
// Check both the first workspace paint and the explicit merge handoff.
|
|
assert.match(render(props), /keep-resumed-conversation/);
|
|
assert.match(render({
|
|
...props,
|
|
activeSessionIdMap: { ...props.activeSessionIdMap, 'workspace:merged': resumed.id },
|
|
panelViewByScope: { 'workspace:merged': { mode: 'session', sessionId: resumed.id } },
|
|
}), /keep-resumed-conversation/);
|
|
assert.equal(aiChatSidePanelPropsAreEqual(props, {
|
|
...props,
|
|
sessions: [{ ...resumed, messages: [...resumed.messages, {
|
|
id: 'm2', role: 'assistant', content: 'continued response', timestamp: 2,
|
|
}] }],
|
|
}), false);
|
|
});
|