// @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 = { '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; }) => ( <> {streaming ? 'streaming' : 'idle'} {isLast ? 'last' : 'not-last'} {showConversationTodoCard && conversationTodoInput ? (
{(conversationTodoInput.todos ?? conversationTodoInput.plan ?? []).map((todo, index) => { const content = 'content' in todo ? todo.content : todo.step; return (
{content}
); })}
) : null} {onShareToOpenDesign ? ( ) : 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 ( <> {streaming ? 'streaming' : 'idle'} ); }), })); class MockResizeObserver { static instances: MockResizeObserver[] = []; callback: ResizeObserverCallback; observed = new Set(); 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(); 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( , ); expect(container.querySelector('.chat-log')?.className).toContain('is-balanced-transcript'); rerender( , ); 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( '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( '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( '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( '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: '
', }, ], }, ]; render( '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( '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( '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( , ); fireEvent.click(screen.getByTestId('share-to-od-assistant-1')); expect(onShareToOpenDesign).toHaveBeenCalledWith('assistant-1'); rerender( , ); const sourceAction = screen.getByTestId('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( 'project-1'} onSend={onSend} onStop={vi.fn()} conversations={conversations} activeConversationId="conv-1" onSelectConversation={vi.fn()} onDeleteConversation={vi.fn()} projectMetadata={projectMetadata} />, ); const spacer = container.querySelector('.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( '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( ', }, ], }, { 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: '
', }, ], }); 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( '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( '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( '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', };