Merge origin/master into worktree/composer-caret-binding

This commit is contained in:
creatixchu
2026-08-04 14:44:09 +08:00
1778 changed files with 129120 additions and 34181 deletions

View File

@@ -15,7 +15,7 @@
// chat-toolview-slot.spec.tsx.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,6 +25,10 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
@@ -101,7 +105,7 @@ async function bench() {
}
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
submit: () => void
}
return { state, actions }
}
@@ -140,25 +144,25 @@ describe('conversation slot inject surface', () => {
const { state, actions } = b.inputSurface(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit('queue')
actions.submit()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(state.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
actions.setDraft('hello')
actions.submit('queue')
actions.submit()
expect(state.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.setDraft('retry me')
actions.submit('queue')
actions.submit()
await vi.waitFor(() => {
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.submit('queue')
actions.submit()
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')
@@ -224,12 +228,9 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
@@ -237,7 +238,7 @@ describe('conversation slot inject surface', () => {
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
})
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
expect(state.getSnapshot().draft).toBe('carry me')

View File

@@ -24,9 +24,13 @@ import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const SID = 's1' as SessionId
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
@@ -145,7 +149,7 @@ describe('terminal card assembly', () => {
const view = runtime.renderRoot()
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
const keyedRow = view.container.querySelector('[data-sample="bash"]')
const keyed = keyedRow?.parentElement
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(keyedRow!)
@@ -252,16 +256,17 @@ describe('prompt rejection through the assembled composer', () => {
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
it('one summary update re-labels the current-session crumb', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
expect(crumb.queryByText('S')).toBeNull()
await waitFor(() => {
expect(within(hierarchy).getByRole('button', { name: '修订标题' }).hasAttribute('disabled')).toBe(true)
})
expect(within(hierarchy).queryByRole('button', { name: 'S' })).toBeNull()
await runtime.dispose()
})
})

View File

@@ -9,12 +9,16 @@
// stops at the assembly surface.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -33,6 +37,7 @@ async function bench() {
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_p: { renderSlot?: unknown }) => null)
const feature = await runtime.mount({ inject: [...inject], apply })
@@ -81,17 +86,19 @@ describe('apply wiring', () => {
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
await b.runtime.dispose()
})
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card; the
// web rows register one component under both web tool names.
// one search row registers under both grep and glob; the web rows register
// one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()
@@ -107,6 +114,7 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
await b.runtime.dispose()
})

View File

@@ -27,7 +27,7 @@ afterEach(() => {
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -36,20 +36,24 @@ describe('MessageItem arms', () => {
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
}}
onFork={onFork}
/>,
)
expect(screen.getByText('14:24')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(1)
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
@@ -74,6 +78,30 @@ describe('MessageItem arms', () => {
expect(exec).toHaveBeenCalledWith('copy')
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'open turn' }] as never,
source: null,
}}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -102,18 +130,28 @@ describe('MessageItem arms', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
it('consumed steering renders copy and branch actions without a badge', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
@@ -164,6 +202,34 @@ describe('MessageItem arms', () => {
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
it('a compaction marker discloses its summary and never shows the framed checkpoint', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'compaction', seq: 5, time: 1_000,
summary: '## 摘要标题\n\n保留的事实。',
}}
/>,
)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/保留的事实/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row).toHaveProperty('disabled', true)
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(view.getByText('压缩摘要不可用')).toBeTruthy()
fireEvent.click(row) // a disabled control stays collapsed
expect(row.getAttribute('aria-expanded')).toBeNull()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
@@ -382,12 +448,15 @@ describe('small branch tails', () => {
})
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
seq={3}
onFork={onFork}
/>,
)
expect(settled.getByText('14:24')).toBeTruthy()
@@ -395,6 +464,8 @@ describe('small branch tails', () => {
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
fireEvent.click(settled.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('answer body')
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(3)
settled.unmount()
const thinkOnly = render(
@@ -417,14 +488,19 @@ describe('small branch tails', () => {
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})

View File

@@ -67,10 +67,10 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -92,7 +92,7 @@ async function bench(snapshot: ConversationSnapshot) {
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
@@ -181,7 +181,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
@@ -264,7 +264,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {

View File

@@ -1,8 +1,7 @@
// @vitest-environment jsdom
// StatsLine (composer.dock entry): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
// chrome (Bash · description) without a row click target.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
@@ -33,9 +32,9 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -58,7 +57,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
describe('deriveStats', () => {
it('folds turns/steps/token split and cache hit percentage', () => {
it('counts turns and steps and never folds node usage into accounting', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -66,12 +65,12 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.inputTokens).toBe(1100)
expect(stats.outputTokens).toBe(100)
expect(stats.cacheHitPct).toBe(82)
// Window-scoped by design: the paged window is not an accounting source, so
// the fold exposes no token fields at all (billing rides the projection).
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
})
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
@@ -79,7 +78,6 @@ describe('deriveStats', () => {
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.toolMs).toBe(0)
expect(stats.cacheHitPct).toBeNull()
})
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
@@ -116,22 +114,105 @@ describe('formatters', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
return (key: string) => values[key]
}
it('renders the grouped stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
function props(
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
// No timing on the fixture: the duration group drops out whole.
// No timing on the fixture: the duration group drops out whole. Tokens come
// from the projection, so paging the window cannot change them.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
const emptyView = render(<StatsLine {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
contextPressure: {},
})} />)
expect(emptyView.container.textContent).toBe('')
})
it('keeps durable token and context groups after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent)
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const withCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
// Pressure without capacity has no denominator: the group drops out.
const noCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000 },
})} />)
expect(noCapacity.container.textContent).not.toContain('Context')
// Capacity arrives before usage in the log; no provider sample means there
// is no numerator yet, rather than a synthetic 0%.
const noPressure = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { contextWindow: 128_000 },
})} />)
expect(noPressure.container.textContent).not.toContain('Context')
})
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
// Capacity and pressure are independent last-wins fields, so a model switch
// can pair a smaller new window with the previous route's larger prompt.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent).toContain('Context 100% of 128K')
})
it('drops every token group when no projection is composed', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps')
})
it('omits cache hit when nothing was billed on the input side', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
})
it('includes cache writes in billed input and the cache-hit denominator', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: {
uncachedInputTokens: 10,
outputTokens: 7,
cacheReadTokens: 90,
cacheWriteTokens: 100,
},
})} />)
expect(view.container.textContent)
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
@@ -150,8 +231,7 @@ describe('StatsLine', () => {
})
describe('bash sample row', () => {
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
const SID = 'root-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId,
@@ -160,68 +240,32 @@ describe('bash sample row', () => {
content: [], isError: false, callView: null, resultView: null,
})
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
ids: [SID],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
}
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
}): BashRowProps => ({
const rowProps = (): BashRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
sessionId: SID,
useSessions: bindSnapshotSelector(listStore()),
t,
} as unknown as BashRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
expect(scoped.getByText('scoped')).toBeTruthy()
const plain = render(<BashRow {...rowProps(ROOT)} />)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a session outside the list renders the global arm (no parent known)', () => {
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
const store = listStore()
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
act(() => {
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
})
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes as Bash · description on both arms without row click targets', () => {
const global = render(<BashRow {...rowProps(ROOT)} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
expect(globalRow.getAttribute('data-clickable')).toBeNull()
const scoped = render(<BashRow {...rowProps(CHILD)} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
it('summarizes as Bash · description without a row click target', () => {
const view = render(<BashRow {...rowProps()} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.textContent).toContain('Bash')
expect(row.textContent).toContain('Build')
expect(row.getAttribute('data-clickable')).toBeNull()
})
})

View File

@@ -320,6 +320,42 @@ describe('ToolRow', () => {
})
describe('ThinkRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown

View File

@@ -91,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => {
const view = b.runtime.renderRoot()
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.

View File

@@ -8,7 +8,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -33,9 +33,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -75,6 +75,11 @@ const retry = (seq: number): ModelRetryNode => ({
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
const turnError = (seq: number, code?: string): TurnErrorNode => ({
kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0,
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
...(code === undefined ? {} : { code }),
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -88,7 +93,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -206,6 +211,29 @@ describe('chat-flow derivation', () => {
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const nodes: ConversationNode[] = [
user(1, 'first'),
assistant(2, 'answer before tools'),
toolResult(3, 'a'),
interruptedThink,
user(6, 'second'),
assistant(7, 'clean tail', 2),
user(10, 'user-only tail'),
{
kind: 'steering', messageId: 'steering-tail' as never,
seq: 13, time: 13_000, turn: 4,
content: [{ type: 'text', text: 'steering tail' }], source: null,
},
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7, 10, 13])
})
})
describe('ChatView', () => {
@@ -246,6 +274,97 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const pending = {
id: 'steer-occurrence' as never,
messageId: 'steer-message' as never,
placement: 'steering' as const,
content: [{ type: 'text' as const, text: 'interrupt now' }],
preview: 'interrupt now',
text: 'interrupt now',
}
const queued = {
id: 'queued-occurrence' as never,
messageId: 'queued-message' as never,
placement: 'queued' as const,
content: [{ type: 'text' as const, text: 'later' }],
preview: 'later',
text: 'later',
}
const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
act(() => {
h.set({
queue: [queued],
nodes: [
assistant(1, 'working'),
{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
content: [{ type: 'text', text: 'interrupt now' }], source: null,
},
],
})
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(unavailable)
expect(h.forkAt).not.toHaveBeenCalled()
act(() => {
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
})
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(branchButtons[1]!)
expect(h.forkAt).toHaveBeenCalledWith(2)
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
const pending = {
id: 'steer-occurrence-later' as never,
messageId: 'shared-steer-message' as never,
placement: 'steering' as const,
content: [{ type: 'text' as const, text: 'same steering' }],
preview: 'same steering',
text: 'same steering',
}
const h = makeHarness({
queue: [pending],
nodes: [{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
content: pending.content, source: null,
}],
running: true,
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByText('same steering')).toHaveLength(2)
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
})
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
@@ -288,6 +407,16 @@ describe('ChatView', () => {
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
})
it('renders terminal turn failures inline with their durable message and optional code', () => {
const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] })
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'本轮运行失败API key is invalidAUTH',
'本轮运行失败plugin exploded',
])
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
@@ -308,21 +437,47 @@ describe('ChatView', () => {
user(5, 'next'),
assistant(6, 'second turn', 2),
],
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
// Every message footer keeps branch visible; only completed assistant tails enable it.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(4)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer')],
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
turnEnds: new Map([[1, 5]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt).not.toHaveBeenCalled()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {

View File

@@ -89,13 +89,15 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
@@ -125,7 +127,7 @@ describe('tails', () => {
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()

View File

@@ -12,7 +12,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
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_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
@@ -24,6 +24,9 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */
type FileMutationRowProps = Parameters<typeof FileMutationRow>[0]
const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
@@ -153,16 +156,27 @@ describe('FileMutationRow diff card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
t,
} as unknown as FileMutationRowProps)
it('renders the applied diff under the summary row, without an expand gesture', () => {
/** 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('collapses to the summary row; expanding reveals the applied diff card', () => {
const view = render(<FileMutationRow {...rowProps(settled())} />)
// The diff card is resident (no expand toggle needed).
// The diff card is collapsed by default — not in the DOM until expanded.
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.queryByText('hello fixture')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
@@ -171,6 +185,7 @@ describe('FileMutationRow diff card', () => {
it('the summary is a path link that opens the tool path through the host', () => {
const openFile = vi.fn()
const view = render(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
// The path link rides the collapsed summary, so it opens without expanding.
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
// The row passes the tool's own path; the injected openFile resolves it
// against the session cwd (apply.ts), so the row must not resolve twice.
@@ -184,6 +199,8 @@ describe('FileMutationRow diff card', () => {
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
// The footer counts live inside the collapsed diff card.
toggleRow(view)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
@@ -197,13 +214,16 @@ describe('FileMutationRow diff card', () => {
it('a mutation call with no diff view renders the summary row alone', () => {
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
// No diff material: expanding shows the args-JSON body, never a diff card.
expect(view.container.querySelector('[data-diff]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).toBeNull()
})
it('surfaces the result text when an errored mutation has no diff card', () => {
// write/edit return undefined from presentResult on isError, so the failure
// has no diff — the row shows the model-facing error text instead of a bare
// red dot.
// has no diff — ToolRow shows the model-facing error text as the collapsed
// summary's first line (errorSummary) instead of a bare red dot.
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null,
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
@@ -220,12 +240,13 @@ describe('FileMutationRow diff card', () => {
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
})
it('shows no failure text for a successful diff or a running call', () => {
it('shows no error summary for a successful diff or a running call', () => {
// ToolRow's error-color summary line is set only on the error state.
const ok = render(<FileMutationRow {...rowProps(settled())} />)
expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
cleanup()
const run = render(<FileMutationRow {...rowProps(running())} />)
expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
})
it('shows the stopped state when the call was interrupted', () => {
@@ -234,7 +255,8 @@ describe('FileMutationRow diff card', () => {
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
// The visually-hidden status label carries the stopped semantic for AT.
// The amber StateDot is aria-hidden, so ToolRow carries the state to AT as
// visually-hidden text; without it a stopped row is a colour-only signal.
expect(view.getByText('已停止')).toBeTruthy()
})
@@ -250,12 +272,12 @@ describe('FileMutationRow diff card', () => {
describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; disposed: boolean }[] = []
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
const disposers: (() => void)[] = []
const ctx = {
slots: {
register: ({ key }: { name: string; key: string }) => {
const entry = { key, disposed: false }
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
const entry = { key, locale, disposed: false }
registered.push(entry)
const dispose = () => { entry.disposed = true }
disposers.push(dispose)
@@ -265,6 +287,8 @@ describe('fileMutationToolview registration', () => {
}
fileMutationToolview.apply(ctx as never)
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// The registrant's inject seam is the load-order contract the row relies on.
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).
@@ -279,12 +303,14 @@ describe('DetailsPanel diff Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -309,10 +335,10 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
import { en } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
localStorage.clear()
})
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}))
}
function emptyWorkspaces() {
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}))
}
function mount() {
const policy = new ComposerSubmissionPolicy()
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
const props: EnterBehaviorRowProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
setBusyEnter,
t: makeTranslate(en),
}
render(<EnterBehaviorRow {...props} />)
return { policy, setBusyEnter }
}
describe('EnterBehaviorRow', () => {
it('explains the busy-only scope and shows Queue by default', () => {
mount()
expect(screen.getByText('Enter behavior while busy')).toBeDefined()
expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined()
expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false')
})
it('selects Steer, follows later preference changes, and closes outside', () => {
const b = mount()
const trigger = screen.getByRole('button', { name: /Queue/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' }))
expect(b.setBusyEnter).toHaveBeenCalledWith('steer')
expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined()
act(() => { b.policy.setBusyEnter('queue') })
const queueTrigger = screen.getByRole('button', { name: /Queue/ })
fireEvent.click(queueTrigger)
expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull()
})
})

View File

@@ -24,9 +24,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -43,20 +43,24 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
it('StatsLine counts window nodes but drops every token group without a projection', () => {
// Node `usage` is deliberately ignored: billing rides the durable
// tokenUsage projection, so an absent projection leaves counts only.
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
// outputTokens absent: the tokens sum's ?? 0 arm for output.
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
expect(view.container.textContent).toBe('2 turns · 3 steps')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
@@ -72,7 +76,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -109,7 +113,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
@@ -35,10 +35,10 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
}
}
@@ -53,6 +53,7 @@ interface BenchOptions {
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
@@ -64,6 +65,7 @@ interface BenchOptions {
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
@@ -88,6 +90,7 @@ function bench(over?: BenchOptions) {
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
@@ -106,6 +109,7 @@ function bench(over?: BenchOptions) {
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -116,6 +120,11 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: (running, gesture, steeringAvailable) => {
if (!running || !steeringAvailable) return 'queue'
const preferred = over?.busyEnter ?? 'queue'
return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
},
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
@@ -135,8 +144,9 @@ function bench(over?: BenchOptions) {
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const stopping = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
@@ -167,12 +177,18 @@ describe('Enter semantics', () => {
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
})
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
const { textarea, shell, sink } = bench({ draft: 'hello' })
textarea.setSelectionRange(5, 5)
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(shell.snapshot.draft).toBe('hello\n')
expect(sink).not.toHaveBeenCalled()
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
const idle = bench({ draft: 'hello' })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
@@ -219,6 +235,72 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(stop).toHaveBeenCalledTimes(1)
})
it('running plain Enter follows the busy-state Steer preference', () => {
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
})
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(stop).not.toHaveBeenCalled()
const empty = bench({
running: true,
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
})
it('keeps both running subagent Enter gestures on Queue transport', () => {
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
@@ -319,8 +401,8 @@ describe('running and lock semantics (queue cut 1)', () => {
})
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste, ctrl-Enter newline and cut suppress the native edit, so no engine
// reveals the caret for them. jsdom has no layout: the rects are stubbed,
// Paste and cut suppress the native edit, so no engine reveals the caret
// for them. jsdom has no layout: the rects are stubbed,
// and what is asserted is the arithmetic — minimal scroll, in both
// directions, and nothing at all for a caret already inside the box.
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
@@ -481,7 +563,7 @@ describe('machine pending lock', () => {
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
shell.submit()
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!

View File

@@ -57,7 +57,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
}
function staleAttempt(): SubmitAttempt {
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
}
describe('input-machine: plain × enter', () => {
@@ -69,14 +69,21 @@ describe('input-machine: plain × enter', () => {
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the given mode', () => {
it('non-command text falls to the default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }])
expect(m.state.phase).toBe('plain')
})
it('retains an explicit steer mode on the default sink effect', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'steer now' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }])
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
@@ -124,7 +131,7 @@ describe('input-machine: adjudication outcomes', () => {
expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
})
it('undefined outcome falls back to the default sink preserving the enter mode', () => {
it('undefined outcome falls back to the default sink', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
@@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => {
})
})
describe('input-machine: newline transaction (F1)', () => {
it('inserts \\n at the caret and shifts trailing occurrences', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
expect(m.state.draft).toBe(`ab\n ${P} `)
expect(m.state.occurrences[0]?.offset).toBe(4)
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`ab ${P} `)
})
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('\n/goal ')
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: consume-token guards', () => {
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
const m = new InputMachine()

View File

@@ -26,10 +26,10 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
})
const props: InputBarProps = {
sessionId: SID,
@@ -37,6 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -46,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: () => 'queue',
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),

View File

@@ -112,10 +112,10 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
})
const barProps: InputBarProps = {
sessionId,
@@ -123,6 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -132,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: () => 'queue',
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
controller.toggleSource('command', {

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, failure notices, and live retirement.
* collapse state, removal, strict steering, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -23,14 +23,18 @@ const SID = 's1' as SessionId
const iid = (id: string): QueueItemId => id as QueueItemId
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
return { id: iid(id), preview, text }
return {
id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
preview, text,
}
}
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -85,6 +89,14 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('leaves pending steering to the conversation flow', () => {
const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
const snap = snapshotWith([steering])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.innerHTML).toBe('')
})
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
const single = snapshotWith([row('i-1', 'one')])
const source = liveSession(single)
@@ -187,10 +199,10 @@ describe('QueueDock', () => {
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('button')).toHaveLength(7)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
@@ -273,6 +285,68 @@ describe('QueueDock', () => {
})
})
it('strictly steers complete row content only while the agent is running', async () => {
const running = snapshotWith([row('i-steer', null, 'image [image]')])
const source = liveSession(running)
const updateQueue = vi.fn(() => Promise.resolve())
const rendered = render(
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
)
const button = rendered.getByLabelText('插话发送')
expect(button).toHaveProperty('disabled', false)
fireEvent.click(button)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
})
act(() => { source.push({ ...running, running: false }) })
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
})
it('renders a session-backed subagent Queue without unsupported actions', () => {
const snap = {
...snapshotWith([row('i-subagent', 'pending child follow-up')]),
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
},
}
const source = liveSession(snap)
const view = render(
<QueueDock {...kitFor(snap)} useSession={source.useSession} />,
)
expect(view.getByText('pending child follow-up')).toBeTruthy()
expect(view.queryByLabelText('编辑排队消息')).toBeNull()
expect(view.queryByLabelText('删除排队消息')).toBeNull()
expect(view.queryByLabelText('插话发送')).toBeNull()
})
it('keeps the row and reports a genuine steer failure', async () => {
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('插话发送'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith(
'error',
'插话发送失败,请重试。',
)
})
expect(getByText('pending steer')).toBeTruthy()
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)

View File

@@ -0,0 +1,323 @@
// @vitest-environment jsdom
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each composing ToolRow with the read card as its collapsed-by-default expanded
// body) and the details panel's Output section (resident, full height). Also
// pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } 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 type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** The chat-view locale seat: this package's namespace over the common fallback. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
// The read tool's real schema key is `file_path`; the top-level read samples
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
function contentTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
}
/** Three windowed lines starting at file line 41 (a read past an offset). */
const sampleLines = [
{ number: 41, text: 'export const a = 1' },
{ number: 42, text: 'export const b = 2' },
{ number: 43, text: 'export const c = 3' },
]
/** The read tool's own result view for a settled file read. */
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
})
describe('readCardModel', () => {
it('derives the card from a settled read result view', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
})
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
const model = readCardModel(settled())
expect(model?.lines).toEqual(sampleLines)
expect(model?.lines).not.toBe(sampleLines)
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
it('takes the result view\'s replacement title over the relativized path', () => {
// The presentation contract defines a result title as REPLACING the pending
// one, so a tool that supplies a label wins over the path here.
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
.toBe('Read (head) src/a.ts')
})
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('carries an omitted language through as undefined', () => {
const noLang = resultRead()
delete (noLang as { lang?: string }).lang
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
// A read carries no content until execute returns, so the pending call is a
// generic card and there is no read card to draw yet.
expect(readCardModel(running())).toBeNull()
})
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
expect(readCardModel(settled({ resultView: null }))).toBeNull()
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(readCardModel(settled({ resultView: future }))).toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
/** 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('expands to the read card, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the read block once expanded.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
// Collapsed: no read card in the DOM yet.
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
expect(view.getByText('41')).toBeTruthy()
})
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
callId: 'c1', toolName: 'echo', block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('a running read renders the summary row alone (no result view yet)', () => {
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as unknown as Parameters<typeof ReadRow>[0])
/** 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('collapses to the path summary; the whole row toggles the read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// Collapsed: the path is the summary link alone, and the card is absent.
expect(view.getAllByText('src/a.ts').length).toBe(1)
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
// Expanded: the summary link stays inline and the card's banner label adds a
// second occurrence of the path.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
// Collapse back in place: the card unmounts, the summary link returns.
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getAllByText('src/a.ts').length).toBe(1)
})
it('the path summary opens the file through the host', () => {
const openFile = vi.fn()
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
// The row derives the file path from args; the chat view resolves it against
// the cwd before this callback opens it, so the arg path is what arrives.
expect(openFile).toHaveBeenCalledWith('src/a.ts')
})
it('a running read renders the summary row alone, and its state', () => {
const view = render(<ReadRow {...rowProps(running())} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an error read result shows the error state and no read card', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: { card: 'generic' }, isError: true,
content: [{ type: 'text', text: 'ENOENT' }],
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an interrupted read shows the stopped state', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
// The row composes ToolRow, so it declares its locale namespace at the seat.
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
expect(readToolview.inject).toContain('conversation')
})
})
describe('DetailsPanel Output section (read)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
t={t}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
expect(contentTexts(view.container)).toContain('row-0')
})
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
})
it('a running read keeps the 运行中… placeholder (no result view)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})

View File

@@ -0,0 +1,440 @@
// @vitest-environment jsdom
// The search render intent on the web side: the pure searchCardModel derivation
// over resultView, and the conversation render sites that consume it — the chat
// tool row (GenericToolCard's fallback body and SearchRow, both composing the
// shared ToolRow with the search card collapsed by default) and the details
// panel's Output section (resident, full height). The keyed registration under
// both grep and glob is pinned here too.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
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_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
import { zh } from '../src/client/locales.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'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
type SearchRowProps = Parameters<typeof SearchRow>[0]
afterEach(cleanup)
/** Conversation-locale translate stub for the render sites' `t` seat. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
/** The rendered search card's kind attribute, so a render site cannot silently drop it. */
function searchKindOf(container: HTMLElement): string | null {
return container.querySelector('[data-search]')?.getAttribute('data-search') ?? null
}
/** The rendered result rows of the search card, one string per visible row. */
function searchRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-search] [class^="_line_"]')].map(row => row.textContent ?? '')
}
const SID = 's1' as SessionId
const GREP_ARGS = '{"pattern":"foo","path":"src"}'
const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}'
/** A grep result view: matches grouped by file. */
const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'matches' }>>): ToolResultView => ({
card: 'search', shape: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
],
truncated: false, total: 3, ...over,
})
/** A glob result view: a flat path list. */
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'paths' }>>): ToolResultView => ({
card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over,
})
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over,
})
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over,
})
describe('searchCardModel', () => {
it('derives a matches card from the grep result view', () => {
expect(searchCardModel(settledGrep())).toEqual({
title: undefined,
recovery: undefined,
card: {
kind: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
],
truncated: false, total: 3,
},
})
})
it('derives a paths card from the glob result view, carrying the truncation signal', () => {
// Empty block content isolates the truncation signal from the recovery arm.
expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
title: undefined,
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
})
})
it('carries the result view\'s replacement title when the presenter sets one', () => {
expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches')
// Without one it is absent, so the row keeps its args-derived summary.
expect(searchCardModel(settledGrep())?.title).toBeUndefined()
})
it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => {
// A search card is result-time only: a running call has no result view yet.
expect(searchCardModel(runningGrep())).toBeNull()
expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull()
// A generic result settles a search call as a generic card (grep/glob failure
// or a nested run_code dispatch), which keeps the generic path.
expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull()
// A terminal result view is a different card entirely.
expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
})
it('returns null for a card:search view whose shape this version does not compile', () => {
// `shape` rides the same untrusted wire frame as `card`; a subtype this client
// does not know must fall to the generic path, never render as a paths card
// that would crash SearchBlock on an absent `paths`.
const futureShape = {
card: 'search', shape: 'future', truncated: false, total: 0,
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
})
it('returns null for a known shape whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`shape` strings but not the grouped
// shape, so a version mismatch could deliver shape:'matches' with no `files`
// (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
// `.reduce`/`.map`; the derivation drops to the generic path instead.
const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
const badFile = {
card: 'search', shape: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
const badPaths = {
card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
} as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
// The recovery locator lives in the raw tool/result content (the view carries
// no text), surfaced only when the card capped the result.
const capped = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the raw content adds nothing and
// is dropped.
const whole = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: false }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the block carries no text: nothing to surface.
const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noText?.recovery).toBeUndefined()
})
})
describe('chat row search body (GenericToolCard fallback)', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
/** 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 grouped matches, capped tighter than the panel', () => {
expect(CHAT_SEARCH_MAX_LINES).toBeLessThan(16)
const view = render(<GenericToolCard {...ownerProps(settledGrep(), 'grep')} />)
// Collapsed: the one-line summary row only, no card.
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(view.getByText('a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('matches')
// The args JSON body the generic path would have shown is gone.
expect(view.queryByText(/"pattern"/)).toBeNull()
})
it('the glob fallback expands to the flat path card', () => {
const view = render(<GenericToolCard {...ownerProps(settledGlob(), 'glob')} />)
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('a non-search result keeps the args-JSON text body', () => {
const view = render(<GenericToolCard {...ownerProps(settledGrep({
resultView: { card: 'generic' },
}), 'grep')} />)
toggleRow(view)
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchKindOf(view.container)).toBeNull()
})
it('the expanded body shows the recovery footer below a capped card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<GenericToolCard {...ownerProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
})
describe('SearchRow keyed card', () => {
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
} as unknown as SearchRowProps)
/** 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('collapses to the summary row; expanding reveals the grep card', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('Search')).toBeTruthy()
// Collapsed: the card is not in the DOM until the row is expanded.
expect(searchKindOf(view.container)).toBeNull()
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
// The card's copy control lives inside the expanded body.
expect(view.getByText('复制')).toBeTruthy()
})
it('expands to the glob path card', () => {
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
expect(searchKindOf(view.container)).toBeNull()
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('agrees with the summary row about the run state', () => {
const runningView = render(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
// No result view yet, so no card even once material could expand.
expect(searchKindOf(runningView.container)).toBeNull()
cleanup()
const errorView = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: { card: 'generic' },
}), 'grep')} />)
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
it('surfaces the result text through the Output section when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
// first error line as the collapsed summary and the full text once expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null,
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
}), 'grep')} />)
expect(searchKindOf(view.container)).toBeNull()
// Error state: the first line is the collapsed summary.
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
toggleRow(view)
// Now in ToolRow's Output section too (the kept summary makes it appear twice).
expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression')
})
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
// ToolRow's Output section carries the text; it is only visible expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: false, resultView: null,
content: [{ type: 'text', text: 'nested run_code output line' }],
}), 'grep')} />)
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
// Collapsed: the ok row shows its args summary, not the output text.
expect(view.queryByText('nested run_code output line')).toBeNull()
toggleRow(view)
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
it('renders the recovery footer below the card when the search was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<SearchRow {...rowProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.container.textContent).not.toMatch(/stored at/)
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
// Error state: the derived name/code line is the collapsed summary.
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
it('shows the result view\'s replacement title instead of the args summary', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ title: '3 matches in 2 files' }),
}), 'grep')} />)
expect(view.getByText('3 matches in 2 files')).toBeTruthy()
})
it('keeps the args-derived summary when the result view has no title', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('foo')).toBeTruthy()
})
it('registers the one row component under both grep and glob keys', () => {
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
},
},
} as never
searchToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['grep', 'glob'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component, two keys.
expect(registered[0]!.component).toBe(SearchRow)
expect(registered[1]!.component).toBe(SearchRow)
expect(searchToolview.inject).toEqual(['slots', 'conversation'])
})
})
describe('DetailsPanel Output section (search)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' }
const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' }
it('renders the grep matches card at full height, keeping the JSON Input section', () => {
const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget)
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
})
it('renders the glob path card', () => {
const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
})
it('a non-search result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settledGrep({ callView: null, resultView: null })],
}), grepTarget)
expect(searchKindOf(view.container)).toBeNull()
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1')
})
})

View File

@@ -33,11 +33,11 @@ async function bench() {
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.send('hello')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
@@ -47,17 +47,40 @@ describe('ConversationService', () => {
it('folds Session business failures into callback rejections', async () => {
const b = await bench()
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' }))
.rejects.toThrow('conversation.updateQueue failed: internal: broken')
await b.runtime.dispose()
})
it('treats strict-steer races as converged Queue delivery', async () => {
const b = await bench()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
.rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed')
await b.runtime.dispose()
})
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
await b.runtime.sessions.remove('s1')
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
await b.runtime.dispose()
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
@@ -65,6 +88,6 @@ describe('ConversationService', () => {
input: new InputHub(bare),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -68,10 +68,10 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
}
}
@@ -80,18 +80,30 @@ function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover = false,
options: {
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover?: boolean
/** The session list summary's `blank` flag — independent of the snapshot's. */
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready',
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
@@ -152,6 +164,7 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
resolveSubmitMode={() => 'queue'}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
@@ -167,7 +180,7 @@ function mount(
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
options.overlayTakeover === true
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
@@ -194,7 +207,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, open, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -209,7 +222,15 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
expect(b.view.queryByText('Root')).toBeNull()
})
it('shows hierarchy only for subagents and opens their ordinary owner', () => {
const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' })
const root = b.view.getByRole('button', { name: 'Root' })
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(root)
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
@@ -229,7 +250,7 @@ describe('ConversationRoot resident composer', () => {
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true })
const seat = b.view.container.querySelector('[data-composer-seat]')
const takeover = b.view.getByTestId('composer-takeover')
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
@@ -270,6 +291,39 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ omitSummaryRow: true },
)
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
})
it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ summaryBlank: true },
)
// The summary already proves the outcome, so the settling hide would only
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
} from '../src/client/input/submission-policy.ts'
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
describe('ComposerSubmissionPolicy', () => {
it('defaults to Queue and only applies the preference while running', () => {
const policy = new ComposerSubmissionPolicy()
expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR)
expect(policy.resolve(false, 'enter', true)).toBe('queue')
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
expect(policy.resolve(true, 'enter', true)).toBe('queue')
expect(policy.resolve(true, 'accelerated', true)).toBe('steer')
expect(policy.resolve(true, 'enter', false)).toBe('queue')
expect(policy.resolve(true, 'accelerated', false)).toBe('queue')
const changed = vi.fn()
policy.busyEnter.subscribe(changed)
policy.setBusyEnter('steer')
expect(changed).toHaveBeenCalledTimes(1)
expect(policy.resolve(true, 'enter', true)).toBe('steer')
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
expect(policy.resolve(false, 'enter', true)).toBe('queue')
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
})
it('restores a valid preference and leaves an identical write untouched', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
const write = vi.spyOn(Storage.prototype, 'setItem')
const policy = new ComposerSubmissionPolicy()
expect(policy.busyEnter.getSnapshot()).toBe('steer')
policy.setBusyEnter('steer')
expect(write).not.toHaveBeenCalled()
write.mockRestore()
})
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', undefined)
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', {
getItem: () => { throw new Error('blocked') },
setItem: vi.fn(),
})
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
})
it('keeps the in-memory preference when persistence throws', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => { throw new Error('quota') },
})
const policy = new ComposerSubmissionPolicy()
policy.setBusyEnter('steer')
expect(policy.busyEnter.getSnapshot()).toBe('steer')
})
})

View File

@@ -345,6 +345,8 @@ describe('BashRow terminal card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
@@ -412,6 +414,28 @@ describe('BashRow terminal card', () => {
}))} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
})
it('expands a generic execution error to its original args and full output', () => {
const view = render(<BashRow {...rowProps(settled({
content: [{ type: 'text', text: 'Error: command aborted' }],
isError: true,
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
resultView: { card: 'generic' },
}))} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.getAttribute('role')).toBe('button')
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('IN')).toBeTruthy()
expect(view.getByText('OUT')).toBeTruthy()
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
})
})
@@ -421,12 +445,14 @@ describe('DetailsPanel Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -451,10 +477,10 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
@@ -605,7 +631,10 @@ describe('DetailsPanel Output section', () => {
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
{
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
}))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,

View File

@@ -99,12 +99,12 @@ describe('TodoDock', () => {
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
it('registers between the goal and queue entries', () => {
it('registers before the goal and queue entries', () => {
expect(todoDockEntry.name).toBe('conversation-todo-dock')
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
})
})

View File

@@ -4,18 +4,20 @@
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
// render-site fallback, and the details panel's Output section. Mirrors
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
// row's resident card, the panel arm, and the keyed registration.
// row's collapsed-by-default ToolRow card, the panel arm, and the keyed
// registration. WebRow now composes the shared ToolRow, so its web card is
// collapsed by default and appears only once the whole row is expanded.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -122,36 +124,48 @@ describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
callId: block.callId, toolName, block, openFile: vi.fn(),
})
// WebRow reads only toolName/block off the full runtime share; the standard
// kit is unused, so the cast supplies the owner slice alone (as BashRow's
// tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
ownerProps(block, toolName) as unknown as ToolRowProps
// WebRow reads only toolName/block off the full runtime share plus the locale
// seat; the standard kit is unused, so the cast supplies the owner slice and
// `t` alone (as BashRow's tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
({ ...ownerProps(block, toolName), t } as unknown as Parameters<typeof WebRow>[0])
it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
expect(CHAT_WEB_MAX_SOURCES).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 WebRow collapses to the summary row, expanding to the full search card', () => {
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// The summary row plus the resident card, without any expand gesture on the row itself.
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// Expanded: the resident search card with every source field.
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// hostname fallback for the source with no title
expect(view.getByText('plain.example.org')).toBeTruthy()
})
it('the WebRow renders the fetch card resident, titled Fetch', () => {
it('the WebRow expands to the fetch card, titled Fetch', () => {
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
expect(view.getByText('Fetch')).toBeTruthy()
// The url shows in the summary row and as the card's link; scope to the card.
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// The url shows as the card's link; scope to the card.
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('a running web call is the summary row alone (no card until it settles)', () => {
it('a running web call is the summary row alone, with nothing to expand', () => {
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
// No card material and no expandable body: clicking the row reveals nothing.
expect(view.container.querySelector('[data-expandable]')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
@@ -165,12 +179,14 @@ describe('chat row web body', () => {
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
// A web-declaring tool without its own keyed row lands on the fallback; its
// card is resident there too.
// card routes through the same collapsed-by-default ToolRow.
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
}), 'fx-web')} t={t} />)
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
expect(view.getByText('Titled')).toBeTruthy()
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
})
@@ -188,7 +204,10 @@ describe('DetailsPanel web Output section', () => {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -212,10 +231,10 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
@@ -250,17 +269,19 @@ describe('DetailsPanel web Output section', () => {
describe('web toolview registration', () => {
it('registers one WebRow under both web_search and web_fetch', () => {
const registered: { key: string; component: unknown }[] = []
const registered: { key: string; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => {}
},
},
} as unknown as import('cordis').Context
webToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)