import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; import type { ConversationSnapshot } from '@/types/coding-conversation'; const interactionApi = vi.hoisted(() => ({ respond: vi.fn(), abort: vi.fn(), model: vi.fn(), thinking: vi.fn(), })); const changesApi = vi.hoisted(() => ({ get: vi.fn(), })); vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ ...await importOriginal(), respondCodingConversationInteraction: interactionApi.respond, abortCodingConversation: interactionApi.abort, setCodingConversationModel: interactionApi.model, setCodingConversationThinking: interactionApi.thinking, })); vi.mock('@/lib/coding-product-tools', async (importOriginal) => ({ ...await importOriginal(), getCodingConversationChanges: changesApi.get, })); describe('PI-130 feature-complete Coding UI', () => { it('summarizes changed files above the composer and expands their diff', async () => { changesApi.get.mockResolvedValueOnce({ changes: { conversationId: 'conversation-changes', runId: 'run-changes', git: true, baselineHead: 'head-changes', files: [ { path: 'src/app.ts', status: 'modified', diff: '--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1,2 @@\n-old\n+new\n+extra', }, { path: 'src/new.ts', status: 'untracked', preview: 'export const created = true;\n', }, ], }, }); const snapshot: ConversationSnapshot = { schemaVersion: 1, conversation: { id: 'conversation-changes', projectId: 'project-1', agentId: 'agent-1', title: 'Changes', model: { model: null, modelResolution: 'required' }, }, nodes: [{ kind: 'tool', id: 'tool-changes', toolCallId: 'call-changes', toolName: 'changed_file', title: '记录文件更改', inputText: '', status: 'complete', output: [], details: { schema: 'changed-file.v1', paths: ['src/app.ts', 'src/new.ts'] }, }], run: { status: 'idle', runId: 'run-changes', startedAt: 1_000, settledAt: 2_000, terminalReason: 'completed', }, queue: { items: [] }, context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' }, pendingInteractions: [], worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq: 1 }, }; const { CodingChangesSummary } = await import('@/pages/Chat/CodingChangesSummary'); render(); const summary = await screen.findByRole('button', { name: '查看 2 个文件的更改' }); expect(summary).toHaveTextContent('2 个文件已更改'); expect(summary).toHaveTextContent('+3'); expect(summary).toHaveTextContent('-1'); fireEvent.click(summary); expect(screen.getByText('src/app.ts')).toBeVisible(); fireEvent.click(screen.getByText('src/app.ts').closest('summary')!); expect(screen.getByText(/\+extra/)).toBeVisible(); expect(changesApi.get).toHaveBeenCalledWith('conversation-changes'); }); it('builds vendor-neutral Conversation model choices without runtime keys', async () => { const { buildCodingModelOptions, parseCodingModelKey } = await import( '@/lib/coding-model-options' ); const account = { id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b', 'model-a'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', } satisfies ProviderAccount; const vendor = { id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo; const options = buildCodingModelOptions([account], [vendor]); expect(options.map((option) => option.modelId)).toEqual(['model-a', 'model-b']); expect(options[0].label).toBe('Work account · Custom vendor / model-a'); expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' }); expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i); }); it('exposes steer/follow-up modes and queue waiting state while a turn runs', async () => { const onModeChange = vi.fn(); const onSubmit = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByText('1 条消息正在等待;本轮结算后会继续处理。')).toBeInTheDocument(); expect(screen.getByText('第 1 位 · 下一轮')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { target: { value: 'steer' } }); expect(onModeChange).toHaveBeenCalledWith('steer'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(onSubmit).toHaveBeenCalledOnce(); }); it('keeps Pi abort available in the restored composer while a turn streams', async () => { const onAbort = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); fireEvent.click(screen.getByRole('button', { name: '中止生成' })); expect(onAbort).toHaveBeenCalledOnce(); }); it('uses the custom title bar for the full-height Conversation columns', async () => { const originalPlatform = window.electron.platform; const { useSettingsStore } = await import('@/stores/settings'); const { CodingConversationSidebar } = await import('@/pages/Chat/CodingConversationSidebar'); const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); window.electron.platform = 'darwin'; useSettingsStore.setState({ sidebarCollapsed: true }); render( <> undefined)} onAbort={vi.fn(async () => undefined)} onRecover={vi.fn(async () => undefined)} /> , ); expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveClass('fixed', 'top-0', 'h-10'); expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveStyle({ left: '0px' }); expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveClass('fixed', 'top-0', 'h-10'); expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '256px' }); expect(screen.queryByText('Pi 本地对话')).not.toBeInTheDocument(); expect(within(screen.getByTestId('coding-conversation-sidebar')) .queryByTestId('coding-conversation-sidebar-header')).not.toBeInTheDocument(); window.electron.platform = originalPlatform; useSettingsStore.setState({ sidebarCollapsed: false }); }); it('unlocks editing and offers recovery after the local Agent exits', async () => { const onRecover = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByRole('textbox')).toBeEnabled(); expect(screen.getByRole('alert')).toHaveTextContent('本地 Agent 已中断,原请求未自动重发。'); expect(screen.queryByText('当前对话正在处理。')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试' })); expect(onRecover).toHaveBeenCalledOnce(); }); it('hides settled interactions, accepts a custom selection, and explains stale responses', async () => { interactionApi.respond.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('409 stale')); const { CodingInteractionPanel } = await import('@/pages/Chat/CodingInteractionPanel'); const onSettled = vi.fn(); const { rerender } = render( , ); expect(screen.queryByText('已经回答的问题')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: '选择方案的其他回答' }), { target: { value: ' 方案 C ' }, }); fireEvent.click(screen.getByRole('button', { name: '提交回答' })); await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', { interactionId: 'interaction-select', value: '方案 C', })); expect(onSettled).toHaveBeenCalledOnce(); rerender( , ); expect(screen.queryByTestId('coding-interactions')).not.toBeInTheDocument(); rerender( , ); fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } }); fireEvent.click(screen.getByRole('button', { name: '提交回答' })); expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument(); }); it('keeps model and thinking inside the composer while legacy metadata actions leave the title bar', async () => { const { useProviderStore } = await import('@/stores/providers'); useProviderStore.setState({ accounts: [{ id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', }], vendors: [{ id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo], }); interactionApi.abort.mockResolvedValue(undefined); interactionApi.model.mockResolvedValue({ model: null, modelResolution: 'required' }); interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' }); const callbacks = { rename: vi.fn(async () => undefined), abort: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), recover: vi.fn(async () => undefined), }; const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); const conversation = { id: 'conversation-1', agentId: 'agent-1', title: 'Feature UI', archivedAt: null, unread: false, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const }, modelResolution: 'resolved' as const, }; const snapshot = { schemaVersion: 1 as const, conversation: { id: 'conversation-1', projectId: 'project-1', agentId: 'agent-1', title: 'Feature UI', model: { model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'high' as const }, modelResolution: 'resolved' as const, availableThinkingLevels: ['off' as const, 'high' as const], }, }, nodes: [], run: { status: 'idle' as const }, queue: { items: [] }, context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' as const }, pendingInteractions: [], worker: { status: 'ready' as const, generation: 1 }, cursor: { workerGeneration: 1, seq: 1 }, }; render( <> , ); const composerRuntimeSlot = screen.getByTestId('coding-message-composer'); expect(within(composerRuntimeSlot).queryByRole('button', { name: '整理上下文' })) .not.toBeInTheDocument(); const settingsTrigger = within(composerRuntimeSlot).getByRole('button', { name: /模型与思考设置/ }); fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false }); const modelRow = await screen.findByRole('menuitem', { name: '模型 model-a' }); fireEvent.click(modelRow); fireEvent.click(await screen.findByRole('menuitemradio', { name: 'model-b' })); await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', { accountId: 'account-1', modelId: 'model-b', thinkingLevel: 'high', })); await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce()); fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false }); const thinkingRow = await screen.findByRole('menuitem', { name: '推理强度 高' }); fireEvent.click(thinkingRow); expect(await screen.findAllByRole('menuitemradio')).toHaveLength(2); expect(screen.getByRole('menuitemradio', { name: '高' })).toBeInTheDocument(); expect(screen.queryByRole('menuitemradio', { name: '中等' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('menuitemradio', { name: '关闭' })); await waitFor(() => expect(interactionApi.thinking).toHaveBeenCalledWith('conversation-1', 'off')); await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledTimes(2)); const header = screen.getByTestId('coding-conversation-header'); expect(within(header).queryByRole('button', { name: '创建分支' })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: '打开编程工具' })).not.toBeInTheDocument(); expect(within(header).queryByText(/Pi ·/)).not.toBeInTheDocument(); expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); }); it('labels a model that only reports off as having no adjustable thinking strength', async () => { const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls'); const conversation = { id: 'conversation-no-thinking', agentId: 'agent-1', title: 'No thinking controls', archivedAt: null, unread: false, createdAt: '2026-08-28T00:00:00.000Z', updatedAt: '2026-08-28T00:00:00.000Z', model: { accountId: 'account-1', modelId: 'model-off-only', thinkingLevel: 'off' as const }, modelResolution: 'resolved' as const, }; const snapshot: ConversationSnapshot = { schemaVersion: 1, conversation: { id: conversation.id, projectId: 'project-1', agentId: conversation.agentId, title: conversation.title, model: { model: conversation.model, modelResolution: 'resolved', availableThinkingLevels: ['off'], }, }, nodes: [], run: { status: 'idle' }, queue: { items: [] }, context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' }, pendingInteractions: [], worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq: 0 }, }; render( undefined)} />, ); const settingsTrigger = screen.getByRole('button', { name: '模型与思考设置:model-off-only,不可调', }); expect(settingsTrigger).toHaveTextContent('不可调'); fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false }); expect(await screen.findByRole('menuitem', { name: '推理强度 不可调' })).toHaveAttribute( 'aria-disabled', 'true', ); expect(screen.queryByRole('menu', { name: '选择推理强度' })).not.toBeInTheDocument(); }); it('shows the native max thinking level as 最高', async () => { const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls'); const conversation = { id: 'conversation-max-thinking', agentId: 'agent-1', title: 'Max thinking controls', archivedAt: null, unread: false, createdAt: '2026-08-28T00:00:00.000Z', updatedAt: '2026-08-28T00:00:00.000Z', model: { accountId: 'account-1', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' as const }, modelResolution: 'resolved' as const, }; const snapshot: ConversationSnapshot = { schemaVersion: 1, conversation: { id: conversation.id, projectId: 'project-1', agentId: conversation.agentId, title: conversation.title, model: { model: conversation.model, modelResolution: 'resolved', availableThinkingLevels: ['off', 'low', 'high', 'max'], }, }, nodes: [], run: { status: 'idle' }, queue: { items: [] }, context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' }, pendingInteractions: [], worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq: 0 }, }; render( undefined)} />, ); const settingsTrigger = screen.getByRole('button', { name: '模型与思考设置:deepseek-v4-pro,最高', }); expect(settingsTrigger).toHaveTextContent('最高'); fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false }); const thinkingRow = await screen.findByRole('menuitem', { name: '推理强度 最高' }); fireEvent.click(thinkingRow); expect(screen.getByRole('menuitemradio', { name: '最高' })).toBeInTheDocument(); expect(screen.queryByRole('menuitemradio', { name: '极简' })).not.toBeInTheDocument(); expect(screen.queryByRole('menuitemradio', { name: '中等' })).not.toBeInTheDocument(); }); it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => { interactionApi.abort.mockResolvedValue(undefined); const recover = vi.fn(async () => undefined); const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls'); const conversation = { id: 'conversation-uncertain', agentId: 'agent-1', title: 'Slow provider', archivedAt: null, unread: false, createdAt: '2026-08-25T00:00:00.000Z', updatedAt: '2026-08-25T00:00:00.000Z', model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const }, modelResolution: 'resolved' as const, }; const snapshot = { schemaVersion: 1 as const, conversation: { id: 'conversation-uncertain', projectId: 'project-1', agentId: 'agent-1', title: 'Slow provider', model: { model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const }, modelResolution: 'resolved' as const, availableThinkingLevels: ['off' as const, 'medium' as const], }, }, nodes: [], run: { status: 'running' as const, runId: 'run-uncertain', mode: 'prompt' as const, error: { code: 'CODING_REQUEST_UNCERTAIN' as const, message: '请求确认延迟,可能仍在执行。', recoverable: true, }, }, queue: { items: [] }, context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' as const }, pendingInteractions: [], worker: { status: 'ready' as const, generation: 1 }, cursor: { workerGeneration: 1, seq: 2 }, }; render( <> undefined)} onAbort={async () => interactionApi.abort('conversation-uncertain')} onRecover={recover} /> undefined)} /> , ); expect(screen.getByRole('button', { name: /模型与思考设置/ })).toBeDisabled(); expect(screen.queryByRole('button', { name: '整理上下文' })).not.toBeInTheDocument(); expect(within(screen.getByTestId('coding-conversation-header')) .queryByRole('button', { name: '创建分支' })).not.toBeInTheDocument(); expect(screen.queryByText('本地编程运行时暂时不可用')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '中止' })); await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-uncertain')); await waitFor(() => expect(screen.getByRole('button', { name: '恢复' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '恢复' })); await waitFor(() => expect(recover).toHaveBeenCalledOnce()); }); });