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
@@ -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.