Merge current master into status bar projection
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user