cleanup(client): extract Tool presentation into ui-tool

This commit is contained in:
imccyu
2026-08-08 15:18:52 +08:00
parent 1674af147e
commit 7cc554ef16
94 changed files with 1797 additions and 1275 deletions

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
// Tool seat ownership and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire or Tool presentation plugin.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
@@ -14,7 +14,7 @@ import type {
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
@@ -137,12 +137,25 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
const t = makeTranslate(zh, commonZh)
const toolOwners: ToolTreeOwnerProps[] = []
const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
if (key !== 'conversation.chat.tool') return opts?.fallback ?? null
const tool = owner as ToolTreeOwnerProps
toolOwners.push(tool)
// Tool providers own their subtree. The host double carries only the
// semantic anchor required by ChatView's prepend-position contract.
return (
<div
data-testid={`tool-seat-${tool.callId}`}
data-chat-anchor-key={`call:${tool.callId}`}
data-chat-call-id={tool.callId}
>
{tool.toolName || '(unnamed)'}:{tool.callId}
</div>
)
}) as unknown as ChatViewSlotProps['renderSlot']
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
// SessionProvider seat arrives with the session-scope child declaration;
@@ -168,10 +181,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
chatScroll,
forkAt,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
t,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
return {
set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
chatScroll, forkAt, setSelection, toolOwners,
}
}
/** Simulate reader input (any device): a delivered position that deviates
@@ -374,14 +390,13 @@ describe('chat-flow derivation', () => {
})
describe('ChatView', () => {
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
const h = makeHarness({
nodes: [{ ...toolResult(3, 'w1'), call: null }],
})
const view = render(<h.ChatView {...h.props} />)
// classifyTool('') → others; the summary slot falls back to the callId.
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.getByText('w1')).toBeTruthy()
expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
})
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
@@ -423,8 +438,8 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('do the thing')).toBeTruthy()
expect(view.getByText('running tools')).toBeTruthy()
expect(view.getAllByText('Bash')).toHaveLength(2)
expect(view.getByText('run a')).toBeTruthy()
expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
key: row.getAttribute('data-chat-flow-key'),
kind: row.getAttribute('data-chat-flow-kind'),
@@ -590,14 +605,12 @@ describe('ChatView', () => {
])
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
it('hands the trajectory callback to the Tool seat', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(h.inspectCall).toHaveBeenCalledWith('a')
render(<h.ChatView {...h.props} />)
expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
})
it('shows assistant IconActions only on the last content message of each turn', () => {
@@ -822,7 +835,7 @@ describe('ChatView', () => {
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.props.renderSlot = ((key: string, _owner: object) => {
if (key !== 'conversation.chat.toolview') return null
if (key !== 'conversation.chat.tool') return null
rowRenders += 1
return <div data-testid="counting-row" />
})
@@ -838,44 +851,19 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the whole-row toggle', () => {
it('updates the selected call id handed to the Tool seat', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).not.toHaveBeenCalled()
expect(h.openFile).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-selected]')).toBeNull()
render(<h.ChatView {...h.props} />)
expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
})
it('clicking a file-tool path summary opens the host file, not details', () => {
const h = makeHarness({
nodes: [{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
}],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('src/a.ts'))
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
expect(h.openDetails).not.toHaveBeenCalled()
})
it('running calls render as a live tool group with the running state', () => {
it('hands running calls to a live Tool group', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('cmd-r1')).toBeTruthy()
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.getByRole('status').textContent).toBe('Deep diving...')
})
@@ -903,19 +891,25 @@ describe('ChatView', () => {
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
})
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
it('hands each ordered root call to the whole-Tool slot', () => {
const block = toolResult(3, 'a')
const h = makeHarness({ nodes: [block] })
const calls: { key: string; owner: object; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
})
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
key: 'conversation.chat.tool',
owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined },
})
const owner = calls[0]?.owner as ToolTreeOwnerProps
expect(owner.block).toBe(block)
expect(owner.openFile).toBe(h.openFile)
expect(owner.inspectCall).toBe(h.inspectCall)
expect(calls[0]?.entryKey).toBeUndefined()
})
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {