Merge latest master into Web transcript

This commit is contained in:
Tianyi Cui
2026-07-31 11:39:49 +08:00
213 changed files with 5435 additions and 912 deletions
@@ -188,9 +188,11 @@ describe('conversation slot inject surface', () => {
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop!
@@ -136,7 +136,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
})
describe('terminal card assembly', () => {
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
// An unregistered tool with terminal views: GenericToolCard fallback.
@@ -144,15 +144,20 @@ describe('terminal card assembly', () => {
])
const view = runtime.renderRoot()
// Keyed BashRow renders the card residently (no expand gesture).
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
const keyed = keyedRow?.parentElement
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(keyedRow!)
await waitFor(() => {
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
})
// Fallback row: card appears only after its expand control.
// Fallback row: same unified expand interaction.
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
await waitFor(() => {
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
})
@@ -205,7 +205,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
@@ -213,8 +213,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
@@ -12,7 +12,7 @@ beforeEach(() => {
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
})
it('actions cover the declared write set', () => {
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
store.actions.setInspect({ callId: 'c1' })
expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
store.actions.setInspect(null)
expect(store.store.getSnapshot().inspect).toBeNull()
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
@@ -6,7 +6,7 @@ afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/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 { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
@@ -107,6 +107,29 @@ describe('tool-call-model', () => {
.toBe('{\n "code": ""\n}')
})
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
.toBe('ToolError: denied')
expect(resultText(result({ content: [] }))).toBe('')
})
it('derives output from the settled result and null while running or blank', () => {
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
expect(toolRowModel('bash', running()).output).toBeNull()
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
})
it('derives errorSummary as the first output line on error rows only', () => {
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
@@ -150,14 +173,15 @@ describe('ToolRow', () => {
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
})
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.container.querySelector('button')!)
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.queryByText('List files')).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
expect(view.getByText(/"a": 1/)).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
@@ -168,16 +192,20 @@ describe('ToolRow', () => {
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
// The dot rides the idle slot, so an expandable error row keeps the
// icon→chevron hover preview instead of losing it with the icon.
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
it('non-expandable rows render a passive leading slot and no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
expect(view.container.querySelector('button')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
it('the row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} />)
const row = view.getByRole('button')
fireEvent.keyDown(row, { key: 'Tab' })
expect(row.getAttribute('aria-expanded')).toBe('false')
@@ -187,32 +215,31 @@ describe('ToolRow', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a non-expandable expandOnRowClick row exposes no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
expect(view.queryByRole('button')).toBeNull()
})
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
it('file rows expand from the row while the path link opens without toggling', () => {
const open = vi.fn()
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
const row = view.getByRole('button', { name: /Read/ })
// Path click opens the file and leaves the row collapsed.
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
// Only the path link is a button — no args-expand affordance on file rows.
expect(view.container.querySelectorAll('button')).toHaveLength(1)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByText(/"a": 1/)).toBeNull()
expect(row.getAttribute('aria-expanded')).toBe('false')
// Row click (outside the link) expands the args body.
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('a single-file path disables expand even when onOpenFile is absent', () => {
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
fireEvent.click(view.getByText('作文.md'))
expect(view.queryByText(/"a": 1/)).toBeNull()
const row = view.getByRole('button')
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('non-file rows do not open anything when the summary is clicked', () => {
@@ -221,6 +248,75 @@ describe('ToolRow', () => {
fireEvent.click(view.getByText('List files'))
expect(open).not.toHaveBeenCalled()
})
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
const view = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
)
expect(view.getByText('boom')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.getByText(/detail/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')).not.toBeNull()
})
it('an error row without an error summary keeps the args summary', () => {
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
expect(view.getByText('List files')).toBeTruthy()
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
<ToolRow
{...rowProps}
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
filePath="src/a.ts" onOpenFile={open}
/>,
)
fireEvent.click(view.getByText('cannot overwrite'))
expect(open).not.toHaveBeenCalled()
// The failure line renders as plain text, not the underlined link button.
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
})
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
const inspect = vi.fn()
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
// Collapsed: no pill.
expect(view.queryByText('Inspect')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
const pill = view.getByText('Inspect')
fireEvent.click(pill)
expect(inspect).toHaveBeenCalledTimes(1)
// The pill click must not collapse the row (body is a .row sibling).
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
})
it('no inspect callback, no pill', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.getByRole('button'))
expect(view.queryByText('Inspect')).toBeNull()
})
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
const both = render(<ToolRow {...rowProps} output="result text" />)
fireEvent.click(both.getByRole('button'))
expect(both.getByText('IN')).toBeTruthy()
expect(both.getByText('OUT')).toBeTruthy()
expect(both.getByText('result text')).toBeTruthy()
cleanup()
const inputOnly = render(<ToolRow {...rowProps} />)
fireEvent.click(inputOnly.getByRole('button'))
expect(inputOnly.getByText('IN')).toBeTruthy()
expect(inputOnly.queryByText('OUT')).toBeNull()
cleanup()
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
fireEvent.click(outputOnly.getByRole('button'))
expect(outputOnly.queryByText('IN')).toBeNull()
expect(outputOnly.getByText('OUT')).toBeTruthy()
expect(outputOnly.getByText('only out')).toBeTruthy()
})
})
describe('ThinkRow', () => {
@@ -241,6 +337,22 @@ describe('ThinkRow', () => {
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
// The summary (first line) is gone from the row; only the body carries it.
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})
describe('GenericToolCard', () => {
@@ -290,6 +402,14 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('passes the owner inspect callback through to the expanded row pill', () => {
const inspect = vi.fn()
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(inspect).toHaveBeenCalledTimes(1)
})
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)
@@ -114,7 +114,7 @@ describe('keyed toolview hole through the real machinery', () => {
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
await b.runtime.dispose()
})
@@ -97,6 +97,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScrollTop: number | null = null
const chatScroll = {
save: (top: number | null) => { savedScrollTop = top },
read: () => savedScrollTop,
}
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
@@ -124,12 +131,14 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
inspectCall,
chatScroll,
forkAt,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
describe('chat-flow derivation', () => {
@@ -218,6 +227,16 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
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')
})
it('shows assistant IconActions only on the last content message of each turn', () => {
const h = makeHarness({
nodes: [
@@ -331,11 +350,11 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the leading slot toggle', () => {
it('tool row expands to the args body via the whole-row toggle', () => {
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('button[aria-expanded]')!)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
@@ -458,6 +477,55 @@ describe('ChatView', () => {
}
})
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
// Reader scrolls up; the position is recorded continuously.
host.scrollTop = 100
fireEvent.scroll(host)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(100)
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
host.remove()
}
})
it('a remount while pinned to the bottom keeps the bottom jump', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />, { container: host })
// At the bottom: the scroll event records the pinned state (null).
fireEvent.scroll(host)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(2000)
} finally {
host.remove()
}
})
it('paging button loads older and shows its busy label', () => {
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
@@ -50,6 +50,8 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
@@ -77,6 +79,7 @@ function bench(over?: BenchOptions) {
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
@@ -100,8 +103,10 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: () => Promise.resolve(true),
// Mirrors the real lookup chain (conversation namespace, then common).
@@ -120,7 +125,7 @@ function bench(over?: BenchOptions) {
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
describe('Enter semantics', () => {
@@ -205,7 +210,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('会话不可用')
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
@@ -438,10 +443,10 @@ describe('strips and variants', () => {
})
})
describe('placeholder chrome and control seats', () => {
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('添加附件')).toBeTruthy()
expect(view.getByLabelText('命令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Both seats dispatched, nothing rendered.
@@ -450,6 +455,18 @@ describe('placeholder chrome and control seats', () => {
expect(view.queryByLabelText('Model')).toBeNull()
})
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
const toggleCommandMenu = vi.fn()
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
textarea.setSelectionRange(2, 7)
const launcher = view.getByLabelText('命令')
expect(launcher.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(launcher)
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
act(() => { menuLauncher.set('command') })
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('the Access chip renders the projection value and submits /permission on pick', async () => {
const permissions = {
options: [
@@ -489,10 +506,10 @@ describe('placeholder chrome and control seats', () => {
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
})
it('disabled locks the Access chip and attach control (running does not)', () => {
it('disabled locks the Access chip and command launcher (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
@@ -46,8 +46,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
@@ -175,7 +177,7 @@ describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea).disabled).toBe(true)
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})
@@ -132,8 +132,18 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
controller.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(controller.launcher),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
@@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => {
// ...and a re-created same-id session starts from a FRESH instance.
const reborn = storeFor(b, 'conversation.session', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
await b.runtime.dispose()
})
})
@@ -152,8 +152,10 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
stop={stop}
command={() => Promise.resolve(true)}
t={t}
@@ -15,7 +15,7 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-conne
import type { SelectionTarget } 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 { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -95,6 +95,19 @@ describe('terminalCardModel', () => {
}))?.card.signal).toBe('SIGTERM')
})
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
// isError stays false on a failing command (the exit status is result
// data), so this predicate is the row's only failure signal.
expect(terminalFailed(terminalCardModel(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled({
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
})
it('takes the result view\'s replacement title over the pending one', () => {
// The presentation contract defines a result title as REPLACING the pending
// title, so a tool that rewrites it at settle time must win here.
@@ -229,36 +242,39 @@ describe('chat row terminal body', () => {
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
})
it('the expanded body is the command output, capped tighter than the panel', () => {
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the expanded body is the command output inside the row scroll container', () => {
const view = render(<GenericToolCard {...ownerProps(settled())} />)
// Collapsed: the one-line summary row only, no output.
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
expect(view.getByText('ls -la')).toBeTruthy()
// The args JSON body the generic path would have shown is gone.
expect(view.queryByText(/"command"/)).toBeNull()
})
it('the cap collapses a long output inside the row, expandable in place', () => {
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
expect(view.getByText('… 其余 3 行')).toBeTruthy()
expect(view.queryByText('line-5')).toBeNull()
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
toggleRow(view)
expect(view.getByText('line-5')).toBeTruthy()
expect(view.getByText('line-19')).toBeTruthy()
expect(view.queryByText(/其余/)).toBeNull()
})
it('renders a multi-line command as one prompt row per line', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ title: 'ls -la\necho done' }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
// Still one dot for the call, on the first row.
@@ -283,14 +299,14 @@ describe('chat row terminal body', () => {
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
expect(view.getByText('Terminal 3')).toBeTruthy()
})
it('a running terminal call expands to the prompt line with no output yet', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
// The card states its own run state: a running command reads as running
@@ -302,7 +318,7 @@ describe('chat row terminal body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: null, resultView: null,
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText(/"command"/)).toBeTruthy()
})
@@ -311,9 +327,16 @@ describe('chat row terminal body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
call: { name: 'bash', argsRaw: '' },
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
})
})
describe('BashRow terminal card', () => {
@@ -330,14 +353,17 @@ describe('BashRow terminal card', () => {
t,
} as unknown as BashRowProps)
it('renders the command output under the summary row, without an expand gesture', () => {
it('collapses to the summary row; the whole row toggles the command output', () => {
const view = render(<BashRow {...rowProps(settled())} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
// The card's controls are the row's only interactions: a bash row is not a
// path link and no longer a details-panel target, so nothing here navigates.
expect(view.container.querySelector('[data-clickable]')).toBeNull()
expect(view.getByText('复制')).toBeTruthy()
// Collapse back in place: the summary row returns, the card unmounts.
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
// The row's leading StateDot and the card's run-state dot describe the same
@@ -346,13 +372,22 @@ describe('BashRow terminal card', () => {
it('agrees with the summary row about the run state', () => {
const runningView = render(<BashRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
expect(runStateOf(runningView.container)).toBe('ongoing')
cleanup()
const settledView = render(<BashRow {...rowProps(settled())} />)
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
expect(runStateOf(settledView.container)).toBe('done')
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<BashRow {...rowProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
})
it('shows the terminal presenter\'s description instead of the args summary', () => {
// `terminal_send`-style presenters author a description the args do not
// repeat; the contract puts it above the card, which is this row's summary.