feat(coding): complete PI conversation UI

This commit is contained in:
2026-08-24 08:59:16 +08:00
parent 2613d6530b
commit 863f005203
21 changed files with 2040 additions and 137 deletions

View File

@@ -53,8 +53,10 @@ async function disableCodingEventSource(page: Page): Promise<void> {
async function installCodingFirstChatHost(
electronApp: ElectronApplication,
hostConnection: HostConnection,
featureComplete = false,
): Promise<void> {
await electronApp.evaluate(async (_, connection) => {
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
@@ -133,6 +135,16 @@ async function installCodingFirstChatHost(
createdAt: now,
updatedAt: now,
};
const secondConversation = {
...conversation,
id: 'conversation-pi-second',
title: 'Second Conversation',
updatedAt: '2026-08-23T23:59:00.000Z',
model: featureComplete
? { accountId: 'account-e2e', modelId: 'model-b', thinkingLevel: 'low' }
: null,
modelResolution: featureComplete ? 'resolved' : 'required',
};
const snapshot = {
schemaVersion: 1,
conversation: {
@@ -142,13 +154,52 @@ async function installCodingFirstChatHost(
title: conversation.title,
model: { model: null, modelResolution: 'required' },
},
nodes: featureComplete ? [{
kind: 'subagent',
id: 'subagent-e2e',
runId: 'run-e2e-feature',
details: {
schema: 'subagent.v1',
dispatchId: 'dispatch-e2e',
mode: 'parallel',
tasks: [
{ taskId: 'task-reader', agentId: 'reader', toolProfile: 'read-only', status: 'complete', summary: 'Read complete' },
{ taskId: 'task-builder', agentId: 'builder', toolProfile: 'coding', status: 'running' },
],
},
}] : [],
run: featureComplete ? { status: 'running', runId: 'run-e2e-feature', mode: 'prompt' } : { status: 'idle' },
queue: { items: featureComplete ? [{ id: 'queue-e2e', clientRequestId: 'request-queued', mode: 'follow-up', text: 'Queued follow-up', attachmentIds: [] }] : [] },
context: featureComplete
? { usedTokens: 256, contextWindow: 4096, compaction: 'idle' }
: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
pendingInteractions: featureComplete ? [{
id: 'interaction-e2e',
conversationId: conversation.id,
runId: 'run-e2e-feature',
kind: 'confirm',
title: '允许继续?',
message: '确认当前实现方向。',
status: 'pending',
}] : [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
};
const secondSnapshot = {
...snapshot,
conversation: {
...snapshot.conversation,
id: secondConversation.id,
title: secondConversation.title,
model: {
model: secondConversation.model,
modelResolution: secondConversation.modelResolution,
},
},
nodes: [],
run: { status: 'idle' },
queue: { items: [] },
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
};
const respond = (json: unknown, status = 200) => ({
ok: true,
@@ -227,21 +278,43 @@ async function installCodingFirstChatHost(
if (path === '/api/coding/projects') {
return respond({ projects: [project], activeProjectId: project.id });
}
if (path === '/api/provider-accounts') {
return respond(featureComplete ? [{
id: 'account-e2e',
vendorId: 'custom',
label: 'E2E account',
authMode: 'api_key',
model: 'model-a',
fallbackModels: ['model-b'],
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
}] : []);
}
if (path === '/api/provider-accounts/key-info') return respond([]);
if (path === '/api/provider-vendors') return respond(featureComplete ? [{ id: 'custom', name: 'Custom' }] : []);
if (path === '/api/provider-accounts/default') return respond({ accountId: featureComplete ? 'account-e2e' : null });
if (path === `/api/coding/projects/config?projectId=${project.id}`) {
return respond({ snapshot: { project, config, knowledgeFiles: [] } });
}
if (path === `/api/coding/projects/conversations?projectId=${project.id}`) {
return respond({ conversations: [] });
return respond({ conversations: featureComplete ? [conversation, secondConversation] : [] });
}
if (path === '/api/coding/projects/conversations' && method === 'POST') {
return respond({ conversation }, 201);
}
if (path === `/api/coding/conversations/${conversation.id}/snapshot`) {
state.snapshotPending = true;
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
if (!featureComplete) {
state.snapshotPending = true;
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
}
return respond({ snapshot });
}
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
return respond({ snapshot: secondSnapshot });
}
if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') {
return respond({
acceptance: {
@@ -253,6 +326,33 @@ async function installCodingFirstChatHost(
},
}, 202);
}
if (/^\/api\/coding\/conversations\/[^/]+\/(abort|compact|recover)$/.test(path) && method === 'POST') {
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
return respond({ model: { model: body?.model, modelResolution: 'resolved' } });
}
if (/^\/api\/coding\/conversations\/[^/]+\/thinking$/.test(path) && method === 'POST') {
return respond({ model: snapshot.conversation.model });
}
if (/^\/api\/coding\/conversations\/[^/]+\/fork$/.test(path) && method === 'POST') {
return respond({ conversation: { ...conversation, id: 'conversation-forked', title: 'Feature UI branch' } }, 201);
}
if (/^\/api\/coding\/conversations\/[^/]+$/.test(path) && method === 'PATCH') {
return respond({ conversation: { ...conversation, ...(body?.title ? { title: body.title } : {}), unread: body?.unread === true, archivedAt: body?.archived === true ? now : null } });
}
if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(path) && method === 'POST') {
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/changes$/.test(path)) {
return respond({ changes: { conversationId: conversation.id, runId: 'run-e2e-feature', git: true, baselineHead: 'head-e2e', files: [{ path: 'src/app.ts', status: 'modified', diff: '+feature UI' }] } });
}
if (path === '/api/coding/files/status') return respond({ files: [{ path: 'src/app.ts', name: 'app.ts', type: 'file', status: 'modified' }] });
if (path.startsWith('/api/coding/files/content?')) return respond({ file: { path: 'src/app.ts', content: 'export const app = true;', truncated: false } });
if (path.startsWith('/api/coding/files/find?')) return respond({ files: [{ path: 'src/app.ts', name: 'app.ts', type: 'file' }] });
if (path.startsWith('/api/coding/skills')) return respond({ skills: [{ id: 'research', name: 'Research', description: 'Inspect sources', selected: true }] });
if (/^\/api\/coding\/conversations\/[^/]+\/commands$/.test(path)) return respond({ commands: [{ name: 'review', title: 'Review', description: 'Review changes', source: 'makelore' }] });
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
if (path.startsWith('/api/opencode/status')) {
return respond({ state: 'stopped', port: null, url: null });
}
@@ -272,7 +372,7 @@ async function installCodingFirstChatHost(
}
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, hostConnection);
}, { connection: hostConnection, featureComplete });
}
async function readState(electronApp: ElectronApplication): Promise<{
@@ -385,3 +485,62 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
await releaseSnapshot(electronApp);
}
});
test('PI feature UI isolates Conversations and exposes queue, interaction, model, subagent, and project tools', async ({
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection, true);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/opencode-chat'; });
await expect(page.getByTestId('coding-conversation-header')).toContainText('生成中');
await expect(page.getByTestId('coding-conversation-header')).toContainText('队列 1');
await expect(page.getByText('并行子任务')).toBeVisible();
await expect(page.getByText('允许继续?')).toBeVisible();
await page.getByRole('button', { name: '确认', exact: true }).click();
const mode = page.getByRole('combobox', { name: '消息发送方式' });
await mode.selectOption('follow-up');
await expect(mode).toHaveValue('follow-up');
const model = page.getByRole('combobox', { name: '当前对话模型' });
await model.selectOption(JSON.stringify(['account-e2e', 'model-b']));
await page.getByRole('button', { name: '中止' }).click();
await page.getByRole('button', { name: '打开编程工具' }).click();
await expect(page.getByRole('dialog')).toContainText('编程工具');
await page.getByRole('tab', { name: '命令' }).click();
await page.getByRole('button', { name: /\/review/ }).click();
await expect(page.getByRole('textbox')).toHaveValue('/review ');
await page.getByRole('button', { name: 'Second Conversation' }).first().click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue(
JSON.stringify(['account-e2e', 'model-b']),
);
await expect(page.getByRole('textbox')).toHaveValue('');
await expect(page.getByText(/分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0);
const state = await readState(electronApp);
expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/abort') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.includes('/interactions/') && request.path.endsWith('/respond'))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/commands'))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/changes'))).toBe(true);
expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true);
} finally {
await releaseSnapshot(electronApp);
}
});

View File

@@ -103,12 +103,20 @@ const projectApi = vi.hoisted(() => ({
config: vi.fn(),
conversations: vi.fn(),
create: vi.fn(),
patch: vi.fn(),
}));
const conversationApi = vi.hoisted(() => ({
snapshot: vi.fn(),
events: vi.fn(),
submit: vi.fn(),
recover: vi.fn(),
abort: vi.fn(),
model: vi.fn(),
thinking: vi.fn(),
compact: vi.fn(),
fork: vi.fn(),
respond: vi.fn(),
diagnostics: vi.fn(),
}));
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
@@ -117,6 +125,7 @@ vi.mock('@/lib/coding-projects', () => ({
getCodingProjectConfig: projectApi.config,
listCodingProjectConversations: projectApi.conversations,
createCodingProjectConversation: projectApi.create,
patchCodingProjectConversation: projectApi.patch,
}));
vi.mock('@/lib/coding-conversations', () => ({
@@ -124,6 +133,13 @@ vi.mock('@/lib/coding-conversations', () => ({
openCodingConversationEvents: conversationApi.events,
submitCodingConversationPrompt: conversationApi.submit,
recoverCodingConversation: conversationApi.recover,
abortCodingConversation: conversationApi.abort,
setCodingConversationModel: conversationApi.model,
setCodingConversationThinking: conversationApi.thinking,
compactCodingConversation: conversationApi.compact,
forkCodingConversation: conversationApi.fork,
respondCodingConversationInteraction: conversationApi.respond,
getCodingRuntimeDiagnostics: conversationApi.diagnostics,
}));
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
@@ -200,6 +216,7 @@ describe('CodingChatPanel first Conversation', () => {
'utf8',
);
expect(sourceText).not.toMatch(/(?:import|export)[\s\S]*?from\s+['"][^'"]*opencode/i);
expect(sourceText).not.toMatch(/['"](?:share|unshare|unrevert|revert|todo|todos|global runtime)['"]/i);
}
});
@@ -539,6 +556,8 @@ describe('CodingChatPanel first Conversation', () => {
preparing
recovering={false}
runStatus="preparing"
mode="prompt"
queue={{ items: [] }}
error={null}
recoverableError={false}
acceptedCount={1}
@@ -546,6 +565,7 @@ describe('CodingChatPanel first Conversation', () => {
submitting={false}
placeholder="Message"
onChange={vi.fn()}
onModeChange={vi.fn()}
onSubmit={onSubmit}
onRecover={vi.fn()}
onAddFiles={vi.fn()}

View File

@@ -159,4 +159,105 @@ describe('CodingConversationTimeline', () => {
expect(commits).toEqual([]);
});
it('renders nested subagents, compacted retry state, inline tool output, and explicit branch wording', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-feature-ui', 1);
const snapshot = {
...base,
nodes: [
{
kind: 'message' as const,
id: 'message-feature',
sourceEntryId: 'entry-feature',
role: 'assistant' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
},
{
kind: 'tool' as const,
id: 'tool-feature',
toolCallId: 'call-feature',
toolName: 'read_file',
title: '读取文件',
inputText: 'src/app.ts',
status: 'complete' as const,
output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }],
details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] },
},
{
kind: 'subagent' as const,
id: 'subagent-feature',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-feature',
mode: 'parallel' as const,
tasks: [
{ taskId: 'task-a', agentId: 'reader', toolProfile: 'read-only' as const, status: 'complete' as const, summary: 'Read complete' },
{ taskId: 'task-b', agentId: 'builder', toolProfile: 'coding' as const, status: 'error' as const, errorCode: 'BUILD_FAILED' },
],
},
},
{
kind: 'subagent' as const,
id: 'subagent-single',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-single',
mode: 'single' as const,
tasks: [{ taskId: 'task-single', agentId: 'reviewer', toolProfile: 'read-only' as const, status: 'aborted' as const }],
},
},
{
kind: 'subagent' as const,
id: 'subagent-chain',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-chain',
mode: 'chain' as const,
tasks: [
{ taskId: 'task-chain-a', agentId: 'planner', toolProfile: 'read-only' as const, status: 'complete' as const },
{ taskId: 'task-chain-b', agentId: 'implementer', toolProfile: 'coding' as const, status: 'skipped' as const },
],
},
},
{
kind: 'compaction' as const,
id: 'compact-feature',
runId: 'run-feature',
source: 'automatic' as const,
status: 'error' as const,
willRetry: true,
summary: '保留了本轮关键上下文。',
},
],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-feature-ui',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
const onFork = vi.fn();
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
expect(onFork).toHaveBeenCalledWith('entry-feature');
expect(screen.getByText('并行子任务')).toBeInTheDocument();
expect(screen.getByText('单个子任务')).toBeInTheDocument();
expect(screen.getByText('串行子任务')).toBeInTheDocument();
expect(screen.getByText('reader')).toBeInTheDocument();
expect(screen.getByText('BUILD_FAILED')).toBeInTheDocument();
expect(screen.getByText('系统会自动重试。')).toBeInTheDocument();
expect(screen.getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
});
});

View File

@@ -79,4 +79,62 @@ describe('coding Conversations Host facade', () => {
'events:/api/coding/events?conversationId=conversation%2Fa',
]);
});
it('routes every PI-130 Conversation control through the product Host surface', async () => {
const model = {
model: { accountId: 'account/1', modelId: 'model/1', thinkingLevel: 'medium' },
modelResolution: 'resolved',
};
const forked = { id: 'forked-1' };
const diagnostics = { revision: { provider: 1, resources: 2 }, workers: [] };
hostApi.fetch
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ model })
.mockResolvedValueOnce({ model })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ conversation: forked })
.mockResolvedValueOnce({ interactions: [{ id: 'interaction-1' }] })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ runtime: diagnostics });
const facade = await import('@/lib/coding-conversations');
await facade.abortCodingConversation('conversation/a');
await expect(facade.setCodingConversationModel('conversation/a', model.model)).resolves.toBe(model);
await expect(facade.setCodingConversationThinking('conversation/a', 'high')).resolves.toBe(model);
await facade.compactCodingConversation('conversation/a');
await facade.recoverCodingConversation('conversation/a');
await expect(facade.forkCodingConversation('conversation/a', 'entry/1')).resolves.toBe(forked);
await facade.listCodingConversationInteractions('conversation/a');
await facade.respondCodingConversationInteraction('conversation/a', {
interactionId: 'interaction/1',
optionId: 'option-1',
});
await expect(facade.getCodingRuntimeDiagnostics()).resolves.toBe(diagnostics);
expect(hostApi.fetch.mock.calls).toEqual([
['/api/coding/conversations/conversation%2Fa/abort', { method: 'POST', body: '{}' }],
['/api/coding/conversations/conversation%2Fa/model', {
method: 'POST',
body: JSON.stringify({ model: model.model }),
}],
['/api/coding/conversations/conversation%2Fa/thinking', {
method: 'POST',
body: JSON.stringify({ thinkingLevel: 'high' }),
}],
['/api/coding/conversations/conversation%2Fa/compact', { method: 'POST', body: '{}' }],
['/api/coding/conversations/conversation%2Fa/recover', { method: 'POST', body: '{}' }],
['/api/coding/conversations/conversation%2Fa/fork', {
method: 'POST',
body: JSON.stringify({ sourceEntryId: 'entry/1' }),
}],
['/api/coding/interactions?conversationId=conversation%2Fa'],
['/api/coding/interactions/interaction%2F1/respond', {
method: 'POST',
body: JSON.stringify({ conversationId: 'conversation/a', optionId: 'option-1' }),
}],
['/api/coding/runtime/diagnostics'],
]);
expect(JSON.stringify(hostApi.fetch.mock.calls)).not.toContain('/api/opencode');
});
});

View File

@@ -0,0 +1,220 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
const interactionApi = vi.hoisted(() => ({
respond: vi.fn(),
abort: vi.fn(),
compact: vi.fn(),
model: vi.fn(),
thinking: vi.fn(),
}));
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/coding-conversations')>(),
respondCodingConversationInteraction: interactionApi.respond,
abortCodingConversation: interactionApi.abort,
compactCodingConversation: interactionApi.compact,
setCodingConversationModel: interactionApi.model,
setCodingConversationThinking: interactionApi.thinking,
}));
describe('PI-130 feature-complete Coding UI', () => {
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(
<CodingComposer
value="Please add tests"
editable
canSend
preparing={false}
recovering={false}
runStatus="running"
mode="follow-up"
queue={{ items: [{ id: 'queue-1', clientRequestId: 'request-1', mode: 'follow-up', text: 'Queued', attachmentIds: [] }] }}
error={null}
recoverableError={false}
acceptedCount={0}
attachments={[]}
submitting={false}
placeholder="Prompt"
onChange={vi.fn()}
onModeChange={onModeChange}
onSubmit={onSubmit}
onRecover={vi.fn()}
onAddFiles={vi.fn()}
onRemoveAttachment={vi.fn()}
/>,
);
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('answers product interactions 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(
<CodingInteractionPanel
conversationId="conversation-1"
interactions={[{
id: 'interaction-select',
conversationId: 'conversation-1',
runId: 'run-1',
kind: 'select',
title: '选择方案',
options: [{ id: 'option-a', label: '方案 A' }],
status: 'pending',
}]}
onSettled={onSettled}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '方案 A' }));
await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', {
interactionId: 'interaction-select',
optionId: 'option-a',
}));
expect(onSettled).toHaveBeenCalledOnce();
rerender(
<CodingInteractionPanel
conversationId="conversation-1"
interactions={[{
id: 'interaction-input',
conversationId: 'conversation-1',
runId: 'run-1',
kind: 'input',
title: '输入名称',
status: 'pending',
}]}
/>,
);
fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } });
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument();
});
it('keeps model, thinking, abort, metadata, and fork controls on the selected Conversation', 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),
archive: vi.fn(async () => undefined),
unread: vi.fn(async () => undefined),
fork: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
recover: vi.fn(async () => undefined),
inspector: vi.fn(),
};
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
render(
<CodingConversationHeader
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' },
modelResolution: 'resolved',
}}
snapshot={{
schemaVersion: 1,
conversation: {
id: 'conversation-1',
projectId: 'project-1',
agentId: 'agent-1',
title: 'Feature UI',
model: {
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
},
},
nodes: [],
run: { status: 'running', runId: 'run-1', mode: 'prompt' },
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 1 },
}}
connectionState="live"
onRename={callbacks.rename}
onArchive={callbacks.archive}
onToggleUnread={callbacks.unread}
onFork={callbacks.fork}
onRefresh={callbacks.refresh}
onRecover={callbacks.recover}
onOpenInspector={callbacks.inspector}
/>,
);
fireEvent.change(screen.getByRole('combobox', { name: '当前对话模型' }), {
target: { value: JSON.stringify(['account-1', 'model-b']) },
});
await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', {
accountId: 'account-1',
modelId: 'model-b',
thinkingLevel: 'medium',
}));
await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: '中止' }));
await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-1'));
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
});
});

View File

@@ -12,12 +12,16 @@ describe('coding project Host facade', () => {
.mockResolvedValueOnce({ projects: [], activeProjectId: null })
.mockResolvedValueOnce({ snapshot: { project: { id: 'project/a' }, config: {} } })
.mockResolvedValueOnce({ conversations: [] })
.mockResolvedValueOnce({ conversation: { id: 'conversation-1' } });
.mockResolvedValueOnce({ conversation: { id: 'conversation-1' } })
.mockResolvedValueOnce({ conversation: { id: 'conversation-1', title: 'Renamed' } })
.mockResolvedValueOnce({});
const {
createCodingProjectConversation,
getCodingProjectConfig,
listCodingProjectConversations,
listCodingProjects,
patchCodingProjectConversation,
deleteCodingProjectConversation,
} = await import('@/lib/coding-projects');
await listCodingProjects();
@@ -28,6 +32,8 @@ describe('coding project Host facade', () => {
agentId: 'agent-1',
title: '新对话',
});
await patchCodingProjectConversation('conversation/1', { title: 'Renamed', unread: true });
await deleteCodingProjectConversation('conversation/1');
expect(hostApiFetch.mock.calls).toEqual([
['/api/coding/projects'],
@@ -37,6 +43,11 @@ describe('coding project Host facade', () => {
method: 'POST',
body: JSON.stringify({ projectId: 'project/a', agentId: 'agent-1', title: '新对话' }),
}],
['/api/coding/conversations/conversation%2F1', {
method: 'PATCH',
body: JSON.stringify({ title: 'Renamed', unread: true }),
}],
['/api/coding/conversations/conversation%2F1', { method: 'DELETE' }],
]);
});
});

View File

@@ -121,4 +121,39 @@ describe('coding workspace store', () => {
expect(store.getState().conversations).toEqual([created]);
expect(store.getState().creatingAgentIds).toEqual({});
});
it('keeps title, archive, and unread metadata scoped to the patched Conversation', async () => {
const first = conversation('conversation-a', 'agent-a');
const second = conversation('conversation-b', 'agent-a');
const patchConversation = vi.fn(async (_id: string, patch: { title?: string; archived?: boolean; unread?: boolean }) => ({
...first,
...(patch.title ? { title: patch.title } : {}),
...(patch.archived ? { archivedAt: '2026-08-24T01:00:00.000Z' } : {}),
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
}));
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
listConversations: vi.fn(async () => [first, second]),
createConversation: vi.fn(),
patchConversation,
});
await store.getState().load();
await store.getState().patchConversation(first.id, {
title: 'Feature UI',
archived: true,
unread: true,
});
expect(patchConversation).toHaveBeenCalledWith(first.id, {
title: 'Feature UI',
archived: true,
unread: true,
});
expect(store.getState().conversations).toEqual([
expect.objectContaining({ id: first.id, title: 'Feature UI', unread: true, archivedAt: expect.any(String) }),
second,
]);
});
});