diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 2d16e91d26..0770037a70 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -145,7 +145,7 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) - it('renders edit and expands fixture reasoning from either click target', async () => { + it('renders file tool rows and expands fixture reasoning from either click target', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) await page.locator('[role="treeitem"]').first().click() await page.locator('[role="treeitem"][aria-selected]').first().click() @@ -166,6 +166,11 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) + + const writeRoot = page.locator('[data-variant="write"]').first() + await writeRoot.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1) + expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1b459198ae..bbf4809466 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -69,9 +69,9 @@ function buildAlphaLog(): SessionEvent[] { } push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card - // type. The generic sample uses the real `edit` name so the fallback row also exercises its - // dedicated icon/title/path summary. `echo` above stays presenter-less as the unknown fallback. + // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in + // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above + // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -89,6 +89,7 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') + toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') return events as unknown as SessionEvent[] } @@ -115,6 +116,8 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { } case 'edit': return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } + case 'write': + return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ddbc8461f0..3bf1c3bd6e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Generic tool rows classify the built-in bash, read, search, and edit names into dedicated visual variants. The edit variant renders the edit icon and `Edit · ` summary while retaining the shared row-to-details interaction. +Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index a772b1b191..958a90526f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -17,6 +17,7 @@ const VARIANT_ICONS: Record = { search: , read: , bash: , + write: , edit: , others: , } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 9a3978cf5a..ea566eb248 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -10,18 +10,19 @@ export type { ToolCallBlock } from './toolview.ts' /** The frozen slice the chat view hands to toolview components as `block` * (both members are cache-stable references off ConversationSnapshot). */ -/** The six figma row variants (think is fed by reasoning blocks, not tool calls). */ -export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'edit' | 'others' +/** The seven row variants (think is fed by reasoning blocks, not tool calls). */ +export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others' /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' /** Figma row titles per variant (design literals, not translatable copy). */ export const VARIANT_TITLES: Record = { - think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', edit: 'Edit', others: 'Tool call', + think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', + write: 'Write', edit: 'Edit', others: 'Tool call', } -/** Known tool name -> variant; fs write intentionally fall to others (no figma form). */ +/** Known tool name -> variant. */ const TOOL_VARIANTS: Record = { bash: 'bash', read: 'read', @@ -29,6 +30,7 @@ const TOOL_VARIANTS: Record = { web_search: 'search', grep: 'search', glob: 'search', + write: 'write', edit: 'edit', } @@ -79,6 +81,7 @@ const SUMMARY_KEYS: Record = { read: ['path', 'file_path', 'url'], search: ['query', 'pattern', 'url'], think: [], + write: ['path', 'file_path'], edit: ['path', 'file_path'], others: [], } diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 8cbc736615..f8a74adeef 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -29,6 +29,7 @@ describe('tool-call-model', () => { expect(classifyTool('web_fetch')).toBe('read') expect(classifyTool('web_search')).toBe('search') expect(classifyTool('grep')).toBe('search') + expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') expect(classifyTool('todo_write')).toBe('others') }) @@ -50,6 +51,7 @@ describe('tool-call-model', () => { it('keeps summaries single-line and falls back for opaque args', () => { expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a') expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts') + expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts') expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts') // Others rows prefix the real tool name into the summary slot (figma-flows // ruling: static "Tool call" title, name rides the mutable summary). @@ -173,6 +175,19 @@ describe('GenericToolCard', () => { expect(view.container.querySelector('svg')).not.toBeNull() }) + it('renders write with its dedicated title, icon variant, and path summary', () => { + const view = render( + , + ) + expect(view.getByText('Write')).toBeTruthy() + expect(view.getByText('src/x.ts')).toBeTruthy() + expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + }) + it('row click reaches actions.openDetails', () => { const p = props('bash', result()) const view = render()