Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

1110 lines
36 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { forwardRef, useImperativeHandle } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane, buildRunErrorDiagnosticText, retryableAssistantMessage } from '../../src/components/ChatPane';
import { DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX } from '../../src/design-system-auto-prompt';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';
import type { ChatMessage, Conversation, ProjectMetadata } from '../../src/types';
const composerMocks = vi.hoisted(() => ({
focus: vi.fn(),
restoreDraft: vi.fn(),
setDraft: vi.fn(),
}));
const clipboardMocks = vi.hoisted(() => ({
copyToClipboard: vi.fn(async (_text: string) => true),
}));
const translations: Record<string, string> = {
'chat.mode.chat.label': 'Ask',
'chat.mode.design.label': 'Design Agent',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedReorder': 'Drag to reorder',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'avatar.useLocal': 'Use Local CLI',
'chat.copyErrorDiagnostic': 'Copy error diagnostics',
'chat.copyDone': 'Copied!',
};
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => translations[key] ?? key,
}),
useT: () => (key: string) => translations[key] ?? key,
}));
vi.mock('../../src/components/AssistantMessage', () => ({
AssistantMessage: ({
streaming,
message,
isLast,
onShareToOpenDesign,
shareToOpenDesignBusy,
showConversationTodoCard,
conversationTodoInput,
}: {
streaming: boolean;
message: ChatMessage;
isLast?: boolean;
onShareToOpenDesign?: () => void;
shareToOpenDesignBusy?: boolean;
showConversationTodoCard?: boolean;
conversationTodoInput?: {
todos?: Array<{ content: string; status?: string }>;
plan?: Array<{ content?: string; step?: string; status?: string }>;
} | null;
}) => (
<>
<output data-testid={`assistant-streaming-${message.id}`}>{streaming ? 'streaming' : 'idle'}</output>
<output data-testid={`assistant-last-${message.id}`}>{isLast ? 'last' : 'not-last'}</output>
{showConversationTodoCard && conversationTodoInput ? (
<div className="op-card op-todo">
{(conversationTodoInput.todos ?? conversationTodoInput.plan ?? []).map((todo, index) => {
const content = 'content' in todo ? todo.content : todo.step;
return (
<div key={`${content}-${index}`} className={`todo-${todo.status ?? 'pending'}`}>
<span className="todo-text">{content}</span>
</div>
);
})}
</div>
) : null}
{onShareToOpenDesign ? (
<button
type="button"
data-testid={`share-to-od-${message.id}`}
disabled={shareToOpenDesignBusy}
onClick={onShareToOpenDesign}
>
{shareToOpenDesignBusy ? 'Preparing package…' : 'Share to Open Design'}
</button>
) : null}
</>
),
}));
vi.mock('../../src/lib/copy-to-clipboard', () => ({
copyToClipboard: clipboardMocks.copyToClipboard,
}));
vi.mock('../../src/components/ChatComposer', () => ({
ChatComposer: forwardRef(({
onSend,
streaming,
}: {
onSend?: (
prompt: string,
attachments: Array<{ path: string; name: string; kind: 'file' }>,
commentAttachments: Array<{ id: string; order: number; filePath: string; comment: string }>,
) => void;
streaming: boolean;
}, ref) => {
useImperativeHandle(ref, () => ({
focus: composerMocks.focus,
restoreDraft: composerMocks.restoreDraft,
setDraft: composerMocks.setDraft,
}));
return (
<>
<output data-testid="composer-streaming">{streaming ? 'streaming' : 'idle'}</output>
<button
type="button"
data-testid="composer-submit"
onClick={() => onSend?.(
'Use a bolder export button',
[{ path: 'edited.md', name: 'edited.md', kind: 'file' }],
[{ id: 'edited-comment', order: 1, filePath: 'preview.html', comment: 'Bolder' }],
)}
>
submit composer
</button>
</>
);
}),
}));
class MockResizeObserver {
static instances: MockResizeObserver[] = [];
callback: ResizeObserverCallback;
observed = new Set<Element>();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.instances.push(this);
}
observe = (target: Element) => {
this.observed.add(target);
};
unobserve = (target: Element) => {
this.observed.delete(target);
};
disconnect = () => {
this.observed.clear();
};
trigger(target: Element) {
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
static triggerObserved(target: Element) {
for (const instance of MockResizeObserver.instances) {
if (instance.observed.has(target)) instance.trigger(target);
}
}
}
function mockDataTransfer(): DataTransfer {
const store = new Map<string, string>();
return {
dropEffect: 'none',
effectAllowed: 'uninitialized',
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
types: [],
clearData: vi.fn((type?: string) => {
if (type) store.delete(type);
else store.clear();
}),
getData: vi.fn((type: string) => store.get(type) ?? ''),
setData: vi.fn((type: string, data: string) => {
store.set(type, data);
}),
setDragImage: vi.fn(),
};
}
beforeEach(() => {
MockResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', MockResizeObserver);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => undefined);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe('ChatPane streaming state', () => {
it('keeps queued-send strip styles compact above the composer', () => {
const css = readExpandedIndexCss();
expect(css).toContain('.chat-queued-send-strip');
expect(css).toContain('display: flex;');
expect(css).toContain('.chat-queued-send-list');
expect(css).toContain('overflow-y: auto;');
expect(css).toContain('.chat-queued-send-row');
expect(css).toContain('display: grid;');
expect(css).toContain('grid-template-columns: 24px minmax(0, 1fr) max-content;');
expect(css).toContain('.chat-queued-send-title');
expect(css).toContain('text-overflow: ellipsis;');
expect(css).toContain('.chat-queued-send-drag-handle');
expect(css).toContain('align-self: auto;');
expect(css).toContain('.pane {');
expect(css).toContain('--chat-composer-inline-inset: 12px;');
expect(css).toContain('.app .split-chat-slot > .pane');
expect(css).toContain('--chat-composer-inline-inset: 10px;');
expect(css).toContain('width: calc(100% - (var(--chat-composer-inline-inset, 12px) * 2));');
expect(css).toContain('margin: 0 var(--chat-composer-inline-inset, 12px) 2px;');
expect(css).toContain('max-width: none;');
expect(css).toContain('.chat-queued-send-action');
expect(css).toContain('width: 24px;');
expect(css).toContain('height: 24px;');
expect(css).toContain('.chat-queued-send-overflow');
expect(css).toContain('.chat-log.is-balanced-transcript > .msg:first-of-type');
expect(css).toContain('margin-top: auto;');
});
it('balances finished transcripts near the composer without affecting active turns', () => {
const baseProps = {
projectKindForTracking: 'prototype' as const,
streaming: false,
error: null,
projectId: 'project-1',
projectFiles: [],
onEnsureProject: async () => 'project-1',
onSend: vi.fn(),
onStop: vi.fn(),
conversations,
activeConversationId: 'conv-1',
onSelectConversation: vi.fn(),
onDeleteConversation: vi.fn(),
projectMetadata,
};
const { container, rerender } = render(
<ChatPane
{...baseProps}
messages={[
{ id: 'user-1', role: 'user', content: 'Make the landing page', createdAt: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'Done', createdAt: 2 },
]}
/>,
);
expect(container.querySelector('.chat-log')?.className).toContain('is-balanced-transcript');
rerender(
<ChatPane
{...baseProps}
streaming
messages={[
{ id: 'user-1', role: 'user', content: 'Make the landing page', createdAt: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'Done', createdAt: 2 },
{
id: 'assistant-2',
role: 'assistant',
content: '',
createdAt: 3,
runStatus: 'running',
},
]}
/>,
);
expect(container.querySelector('.chat-log')?.className).not.toContain('is-balanced-transcript');
});
it('keeps composer popovers above the chat jump button', () => {
const css = readExpandedIndexCss();
expect(css).toContain('.chat-jump-btn');
expect(css).toContain('z-index: 6;');
expect(css).toContain('.composer:has(.composer-tools-menu)');
expect(css).toContain('.composer:has(.composer-design-toolbox-menu)');
expect(css).toContain('.composer:has(.composer-import-menu)');
expect(css).toContain('z-index: 80;');
});
it('exposes retry only for the last failed assistant when the pane is idle', () => {
const failed: ChatMessage = {
id: 'assistant-1',
role: 'assistant',
content: 'Generation failed',
createdAt: 1,
runStatus: 'failed',
};
const messages: ChatMessage[] = [
{ id: 'user-1', role: 'user', content: 'Create a login page', createdAt: 0 },
failed,
];
expect(retryableAssistantMessage(messages, failed.id, false)).toBe(failed);
expect(retryableAssistantMessage(messages, failed.id, true)).toBeNull();
expect(retryableAssistantMessage([...messages, { ...messages[0]!, id: 'user-2' }], failed.id, false))
.toBeNull();
});
it('copies failed-run diagnostics with the trace id from the error card', async () => {
const messages: ChatMessage[] = [
{ id: 'user-1', role: 'user', content: 'Create a login page', createdAt: 0 },
{
id: 'assistant-1',
role: 'assistant',
content: 'Generation failed',
agentId: 'amr',
createdAt: 1,
runId: 'run-trace-123',
runStatus: 'failed',
events: [
{
kind: 'status',
label: 'error',
detail: 'json-rpc id 4: Connection reset by server',
code: 'AGENT_EXECUTION_FAILED',
},
],
},
];
render(
<ChatPane
projectKindForTracking="prototype"
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Copy error diagnostics' }));
await waitFor(() => expect(clipboardMocks.copyToClipboard).toHaveBeenCalledTimes(1));
const copied = clipboardMocks.copyToClipboard.mock.calls[0]?.[0] ?? '';
expect(copied).toContain('trace_id: run-trace-123');
expect(copied).toContain('run_id: run-trace-123');
expect(copied).toContain('error_code: AGENT_EXECUTION_FAILED');
expect(copied).toContain('project_id: project-1');
expect(copied).toContain('conversation_id: conv-1');
expect(copied).toMatch(/^json-rpc id 4: Connection reset by server\n\nOpen Design run error diagnostics/);
expect(copied).not.toContain('raw_error:');
expect(copied).not.toContain('\nerror:\n');
});
it('formats run error diagnostics with a raw error when guidance copy differs', () => {
const text = buildRunErrorDiagnosticText({
message: 'Service unavailable. Try again.',
rawMessage: 'json-rpc id 4: Connection reset by server',
errorCode: 'UPSTREAM_UNAVAILABLE',
traceId: 'run-abc',
projectId: 'project-1',
conversationId: 'conv-1',
assistantMessageId: 'assistant-1',
agentId: 'amr',
});
expect(text).toMatch(/^json-rpc id 4: Connection reset by server\n\nOpen Design run error diagnostics/);
expect(text).not.toContain('raw_error:');
expect(text).toContain('error_code: UPSTREAM_UNAVAILABLE');
expect(text).not.toContain('\nerror:\n');
});
it('falls back to the display message when raw error text is unavailable', () => {
const text = buildRunErrorDiagnosticText({
message: 'Connection dropped. Try again.',
rawMessage: ' ',
errorCode: 'AGENT_CONNECTION_DROPPED',
traceId: 'run-abc',
projectId: 'project-1',
conversationId: 'conv-1',
assistantMessageId: 'assistant-1',
agentId: 'amr',
});
expect(text).toMatch(/^Connection dropped\. Try again\.\n\nOpen Design run error diagnostics/);
expect(text).not.toContain('raw_error:');
expect(text).toContain('error_code: AGENT_CONNECTION_DROPPED');
expect(text).not.toContain('\nerror:\n');
});
it('renders user turns with the chat bubble styling hook', () => {
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: 'Generate a simple sign-in page',
createdAt: 1,
},
];
render(
<ChatPane
projectKindForTracking="prototype"
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const bubble = screen.getByText('Generate a simple sign-in page');
expect(bubble.classList.contains('user-bubble')).toBe(true);
expect(bubble.closest('.msg.user')).not.toBeNull();
});
it('offers a Local CLI recovery action on BYOK error states', () => {
const onSwitchToLocalCli = vi.fn();
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: 'Create a login page',
createdAt: 1,
},
{
id: 'assistant-1',
role: 'assistant',
content: '',
createdAt: 2,
runStatus: 'failed',
events: [
{
kind: 'status',
label: 'error',
detail: 'Missing API key — open Settings and paste one in.',
},
],
},
];
render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
showByokRecoveryAction
onSwitchToLocalCli={onSwitchToLocalCli}
projectMetadata={projectMetadata}
/>,
);
const action = screen.getByRole('button', { name: 'Use Local CLI' });
fireEvent.click(action);
expect(onSwitchToLocalCli).toHaveBeenCalledTimes(1);
});
it('shows the sent mode and applied plugin context on user turns', () => {
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: 'Generate the refinement glow-up deck',
createdAt: 1,
sessionMode: 'design',
runContext: {
workspaceItems: [
{
id: 'browser:tab-1',
kind: 'browser',
label: 'Dribbble',
tabId: 'tab-1',
url: 'https://dribbble.com/',
},
],
},
appliedPluginSnapshot: {
snapshotId: 'snap-refinement',
pluginId: 'refinement-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: {
items: [
{
kind: 'asset',
path: 'template.json',
label: 'template.json',
},
],
},
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 1,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
pluginTitle: 'A Decade of Refinement Glow-Up',
},
},
];
const onRequestOpenFile = vi.fn();
const onRequestPluginDetails = vi.fn();
const onRequestDesignSystemDetails = vi.fn();
const activeDesignSystem = {
id: 'neutral-modern',
title: 'Neutral Modern',
category: 'Starter',
source: 'bundled',
updatedAt: 1,
} as never;
render(
<ChatPane
projectKindForTracking="prototype"
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
onRequestOpenFile={onRequestOpenFile}
onRequestPluginDetails={onRequestPluginDetails}
onRequestDesignSystemDetails={onRequestDesignSystemDetails}
activeDesignSystem={activeDesignSystem}
/>,
);
expect(screen.getByTestId('msg-session-mode-chip').textContent).toContain('Design Agent');
expect(screen.getByTestId('msg-workspace-context-chip').textContent).toContain('Dribbble');
expect(screen.getByTestId('msg-plugin-chip').textContent)
.toContain('A Decade of Refinement Glow-Up');
fireEvent.click(screen.getByTestId('msg-workspace-context-chip'));
expect(onRequestOpenFile).toHaveBeenCalledWith('tab-1');
fireEvent.click(screen.getByTestId('msg-plugin-chip'));
expect(onRequestPluginDetails).toHaveBeenCalledWith('refinement-plugin');
expect(screen.getByTestId('msg-design-system-chip').textContent).toContain('Neutral Modern');
fireEvent.click(screen.getByTestId('msg-design-system-chip'));
expect(onRequestDesignSystemDetails).toHaveBeenCalledWith(activeDesignSystem);
// The plugin's resolved context is now collapsed into the single
// plugin chip — the per-category (asset/design/skill) fan-out is no
// longer rendered in the bubble, even though the full snapshot still
// rides the run for the agent.
expect(screen.queryByText('template.json')).toBeNull();
});
it('hides internal path ids from comment attachment chips', () => {
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: '',
createdAt: 1,
commentAttachments: [
{
id: 'comment-1',
order: 1,
filePath: 'preview.html',
elementId: 'path-0-0-0-0-1',
selector: '[data-od-id="path-0-0-0-0-1"]',
label: '',
comment: '222',
currentText: '',
pagePosition: { x: 10, y: 20, width: 30, height: 40 },
htmlHint: '<div>',
},
],
},
];
render(
<ChatPane
projectKindForTracking="prototype"
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByText('Annotation')).toBeTruthy();
expect(screen.getByText('222')).toBeTruthy();
expect(screen.queryByText('path-0-0-0-0-1')).toBeNull();
});
it('summarizes auto-sent design-system workspace prompts', () => {
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: `${DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX}
Use the files in this project as the design system source for future projects.
Expected output:
- A clear DESIGN.md with all generated rules.`,
createdAt: 1,
},
];
render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByText('Creating design system workspace')).toBeTruthy();
expect(screen.queryByText(DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX, { exact: false })).toBeNull();
expect(screen.queryByRole('button', { name: 'chat.copyPrompt' })).toBeNull();
});
it('keeps composer idle while active-run messages still render as streaming', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'still running',
createdAt: 1,
runId: 'run-1',
runStatus: 'running',
},
];
render(
<ChatPane
projectKindForTracking="prototype"
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByTestId('composer-streaming').textContent).toBe('idle');
expect(screen.getByTestId('assistant-streaming-assistant-1').textContent).toBe('streaming');
});
it('keeps Share to Open Design busy on the assistant turn that started packaging', () => {
const onShareToOpenDesign = vi.fn();
const completedAssistant: ChatMessage = {
id: 'assistant-1',
role: 'assistant',
content: 'Done',
createdAt: 2,
startedAt: 2,
endedAt: 3,
runStatus: 'succeeded',
};
const initialMessages: ChatMessage[] = [
{ id: 'user-1', role: 'user', content: 'Make the landing page', createdAt: 1 },
completedAssistant,
];
const commonProps = {
projectKindForTracking: 'prototype' as const,
streaming: false,
error: null,
projectId: 'project-1',
projectFiles: [],
onEnsureProject: async () => 'project-1',
onSend: vi.fn(),
onStop: vi.fn(),
conversations,
activeConversationId: 'conv-1',
onSelectConversation: vi.fn(),
onDeleteConversation: vi.fn(),
projectMetadata,
onShareToOpenDesign,
};
const { rerender } = render(
<ChatPane
{...commonProps}
messages={initialMessages}
shareToOpenDesignBusyMessageId={null}
/>,
);
fireEvent.click(screen.getByTestId('share-to-od-assistant-1'));
expect(onShareToOpenDesign).toHaveBeenCalledWith('assistant-1');
rerender(
<ChatPane
{...commonProps}
messages={[
...initialMessages,
{ id: 'user-2', role: 'user', content: 'Share to Open Design', createdAt: 4 },
{
id: 'assistant-2',
role: 'assistant',
content: '',
createdAt: 5,
runId: 'run-share-to-od',
runStatus: 'running',
},
]}
shareToOpenDesignBusyMessageId="assistant-1"
/>,
);
const sourceAction = screen.getByTestId<HTMLButtonElement>('share-to-od-assistant-1');
expect(screen.getByTestId('assistant-last-assistant-1').textContent).toBe('not-last');
expect(sourceAction.disabled).toBe(true);
expect(sourceAction.textContent).toBe('Preparing package…');
});
it('clears stale anchor spacer before sending another local turn', () => {
const onSend = vi.fn();
const { container } = render(
<ChatPane
projectKindForTracking="prototype"
messages={[
{ id: 'user-1', role: 'user', content: 'Make the landing page', createdAt: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'Done', createdAt: 2 },
]}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={onSend}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const spacer = container.querySelector<HTMLElement>('.chat-log-tail-spacer');
expect(spacer).not.toBeNull();
spacer!.style.height = '320px';
fireEvent.click(screen.getByTestId('composer-submit'));
expect(onSend).toHaveBeenCalledOnce();
expect(spacer!.style.height).toBe('0px');
});
it('passes a stopped inline todo after a terminal run without a final TodoWrite', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: '',
createdAt: 1,
startedAt: 1,
endedAt: 2,
runStatus: 'failed',
events: [
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{
content: 'Build prototype',
status: 'in_progress',
activeForm: 'Building prototype',
},
{ content: 'Run QA', status: 'pending' },
],
},
},
],
},
];
const { container } = render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(container.querySelectorAll('.chat-log .op-card.op-todo')).toHaveLength(1);
expect(container.querySelector('.todo-stopped .todo-text')?.textContent).toBe('Build prototype');
expect(container.querySelector('.todo-pending .todo-text')?.textContent).toBe('Run QA');
expect(container.querySelector('.chat-pinned-todo')).toBeNull();
});
it('shows several queued prompts above the composer with compact controls', () => {
const onRemoveQueuedSend = vi.fn();
const onSendQueuedNow = vi.fn();
const onUpdateQueuedSend = vi.fn();
const onReorderQueuedSends = vi.fn();
const { container } = render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[
{
id: 'queued-1',
prompt: 'Make the export button larger and use a warmer accent',
attachments: [{ path: 'brief.md', name: 'brief.md', kind: 'file' }],
commentAttachments: [
{
id: 'comment-1',
order: 1,
filePath: 'preview.html',
elementId: 'hero',
selector: '#hero',
label: 'Hero',
comment: 'Use a warmer accent',
currentText: 'Export',
pagePosition: { x: 10, y: 20, width: 30, height: 40 },
htmlHint: '<section id="hero">',
},
],
},
{ id: 'queued-2', prompt: 'Then adjust the title spacing' },
{ id: 'queued-3', prompt: 'Reduce the subtitle size' },
{ id: 'queued-4', prompt: 'Switch to a lighter font weight' },
{ id: 'queued-5', prompt: 'Add hover polish' },
]}
onRemoveQueuedSend={onRemoveQueuedSend}
onSendQueuedNow={onSendQueuedNow}
onUpdateQueuedSend={onUpdateQueuedSend}
onReorderQueuedSends={onReorderQueuedSends}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const strip = container.querySelector('.chat-queued-send-strip');
expect(strip).not.toBeNull();
expect(strip?.textContent).toContain('5 Queued');
expect(strip?.textContent).toContain('to Send');
expect(strip?.textContent).not.toContain('Start Multitasking');
expect(container.querySelectorAll('.chat-queued-send-row')).toHaveLength(5);
expect(strip?.textContent).toContain('Make the export button larger and use a warmer accent');
expect(strip?.textContent).toContain('Then adjust the title spacing');
expect(strip?.textContent).toContain('Reduce the subtitle size');
expect(strip?.textContent).toContain('Switch to a lighter font weight');
expect(strip?.textContent).toContain('Add hover polish');
expect(container.querySelector('.chat-queued-send-list')?.className).toContain('is-scrollable');
expect(container.querySelector('.chat-queued-send-overflow')?.textContent).toContain('+1 more queued');
expect(screen.getAllByRole('button', { name: 'Drag to reorder' })).toHaveLength(5);
const sendNowButtons = screen.getAllByRole('button', { name: 'chat.send' });
fireEvent.click(sendNowButtons[1]!);
expect(onSendQueuedNow).toHaveBeenCalledWith('queued-2');
const editButtons = screen.getAllByRole('button', { name: 'Edit' });
fireEvent.click(editButtons[0]!);
expect(composerMocks.restoreDraft).toHaveBeenCalledWith({
text: 'Make the export button larger and use a warmer accent',
attachments: [{ path: 'brief.md', name: 'brief.md', kind: 'file' }],
commentAttachments: [
{
id: 'comment-1',
order: 1,
filePath: 'preview.html',
elementId: 'hero',
selector: '#hero',
label: 'Hero',
comment: 'Use a warmer accent',
currentText: 'Export',
pagePosition: { x: 10, y: 20, width: 30, height: 40 },
htmlHint: '<section id="hero">',
},
],
});
expect(onUpdateQueuedSend).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('composer-submit'));
expect(onUpdateQueuedSend).toHaveBeenCalledWith('queued-1', {
prompt: 'Use a bolder export button',
attachments: [{ path: 'edited.md', name: 'edited.md', kind: 'file' }],
commentAttachments: [
{ id: 'edited-comment', order: 1, filePath: 'preview.html', comment: 'Bolder' },
],
});
const removeButtons = screen.getAllByRole('button', { name: 'chat.comments.remove' });
fireEvent.click(removeButtons[1]!);
expect(onRemoveQueuedSend).toHaveBeenCalledWith('queued-2');
});
it('reorders queued prompts with the drag handle', () => {
const onReorderQueuedSends = vi.fn();
const { container } = render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[
{ id: 'queued-1', prompt: 'First queued follow-up' },
{ id: 'queued-2', prompt: 'Second queued follow-up' },
{ id: 'queued-3', prompt: 'Third queued follow-up' },
]}
onReorderQueuedSends={onReorderQueuedSends}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const rows = container.querySelectorAll('.chat-queued-send-row');
const handles = screen.getAllByRole('button', { name: 'Drag to reorder' });
const dataTransfer = mockDataTransfer();
const targetRect = {
top: 0,
height: 30,
bottom: 30,
left: 0,
right: 300,
width: 300,
x: 0,
y: 0,
toJSON: () => ({}),
};
Object.defineProperty(rows[2]!, 'getBoundingClientRect', {
configurable: true,
value: () => targetRect,
});
fireEvent.dragStart(handles[0]!, { dataTransfer });
fireEvent.dragOver(rows[2]!, { dataTransfer, clientY: 29 });
fireEvent.drop(rows[2]!, { dataTransfer, clientY: 29 });
expect(onReorderQueuedSends).toHaveBeenCalledWith([
'queued-2',
'queued-3',
'queued-1',
]);
});
it('falls back to the localized queued follow-up label for blank prompts', () => {
render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[{ id: 'queued-1', prompt: ' ' }]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByText('Queued follow-up')).toBeTruthy();
});
it('auto-follows when the queued strip resizes while pinned to bottom', () => {
const { container } = render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[{ id: 'queued-1', prompt: 'First queued follow-up' }]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const log = container.querySelector('.chat-log') as HTMLDivElement | null;
const strip = screen.getByTestId('chat-queued-send-strip');
expect(log).not.toBeNull();
expect(strip).toBeTruthy();
Object.defineProperty(log!, 'scrollHeight', { configurable: true, get: () => 600 });
Object.defineProperty(log!, 'clientHeight', { configurable: true, get: () => 200 });
Object.defineProperty(log!, 'scrollTop', {
configurable: true,
get() {
return (this as HTMLDivElement).dataset.scrollTop
? Number((this as HTMLDivElement).dataset.scrollTop)
: 400;
},
set(value: number) {
(this as HTMLDivElement).dataset.scrollTop = String(value);
},
});
MockResizeObserver.triggerObserved(strip);
expect(log!.scrollTop).toBe(600);
});
});
const conversations: Conversation[] = [
{
id: 'conv-1',
projectId: 'project-1',
title: 'Conversation 1',
createdAt: 1,
updatedAt: 1,
},
];
const projectMetadata: ProjectMetadata = {
kind: 'prototype',
};