Merge current master into status bar projection

This commit is contained in:
Hypatia May
2026-07-31 10:56:34 +08:00
173 changed files with 4960 additions and 699 deletions
@@ -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} />)
@@ -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()
})
})
@@ -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.