Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
@@ -15,8 +15,9 @@
|
||||
// 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'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
@@ -24,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']>
|
||||
@@ -49,6 +54,9 @@ async function bench() {
|
||||
})
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layoutFake)
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
// The AppFrame role: the conversation-package slots must be declared by a
|
||||
// live entry before apply can contribute into them.
|
||||
@@ -97,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 }
|
||||
}
|
||||
@@ -120,6 +128,13 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -129,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')
|
||||
@@ -161,7 +176,7 @@ describe('conversation slot inject surface', () => {
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop()
|
||||
b.composerSurface(ROOT).stop!()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
@@ -170,12 +185,21 @@ describe('conversation slot inject surface', () => {
|
||||
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.composer.bar')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
|
||||
// Unknown session: the keyboard face's binding resolution answers nothing.
|
||||
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
|
||||
expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
|
||||
// No session (session-maybe absent side): machine faces absent, static
|
||||
// hooks compartment still present so the render side's hook order holds.
|
||||
const absent = injectFn(undefined)
|
||||
expect(absent.keyboard).toBeUndefined()
|
||||
expect(absent.toggleCommandMenu).toBeUndefined()
|
||||
expect(absent.stop).toBeUndefined()
|
||||
expect(absent.hooks.notices.getSnapshot()).toBeNull()
|
||||
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
|
||||
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
|
||||
// A scope whose service tree lost 'conversation' (the feature fiber
|
||||
// unloaded while a retained inject closure re-runs): fails loud too.
|
||||
const stop = injectFn(ROOT).stop
|
||||
const stop = injectFn(ROOT).stop!
|
||||
await b.feature.dispose()
|
||||
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
|
||||
await b.runtime.dispose()
|
||||
@@ -204,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))
|
||||
@@ -217,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')
|
||||
@@ -294,7 +315,7 @@ describe('conversation slot inject surface', () => {
|
||||
// Label falls back to the id when a rider declares none.
|
||||
const off2 = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
|
||||
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
|
||||
expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
|
||||
off()
|
||||
off2()
|
||||
unsub()
|
||||
|
||||
137
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
137
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ask_user_question toolview acceptance: `waiting` summary while running,
|
||||
* answered-count from the result JSON once settled (skipped answers
|
||||
* excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
|
||||
* ASK_ABORTED, shared ToolRow state
|
||||
* semantics for interrupted/failed calls, and generic fallbacks on
|
||||
* malformed results.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
|
||||
|
||||
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'ask_user_question', argsRaw },
|
||||
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
|
||||
isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
|
||||
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain.
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
|
||||
return {
|
||||
callId: 'c1', toolName: 'ask_user_question', block, t,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as Parameters<typeof AskQuestionRow>[0]
|
||||
}
|
||||
|
||||
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
|
||||
|
||||
describe('AskQuestionRow', () => {
|
||||
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
|
||||
expect(screen.getByText('提问')).toBeTruthy()
|
||||
expect(screen.getByText('等待回答')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('settled result counts answered entries (selected choices or custom text)', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
||||
{ id: 'a', selected: ['x'] },
|
||||
{ id: 'b', selected: [], custom: 'freeform' },
|
||||
{ id: 'c', selected: ['y', 'z'], custom: '' },
|
||||
])))} />)
|
||||
expect(screen.getByText('3/3 已回答')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
||||
{ id: 'a', selected: ['x'] },
|
||||
{ id: 'b', selected: [], custom: '' },
|
||||
{ id: 'c' },
|
||||
])))} />)
|
||||
expect(screen.getByText('1/3 已回答')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'non-JSON result text', text: 'oops' },
|
||||
{ label: 'non-object result root', text: '"str"' },
|
||||
{ label: 'null result root', text: 'null' },
|
||||
{ label: 'missing answers array', text: '{"other":1}' },
|
||||
{ label: 'null answer entries', text: '{"answers":[null]}' },
|
||||
{ label: 'empty result content', text: null },
|
||||
])('settled result falls back to the generic summary on $label', ({ text }) => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('user cancellation names the verdict instead of the generic failed shape', () => {
|
||||
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
|
||||
expect(screen.getByText('已取消')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a turn abort while pending reads interrupted with stopped semantics', () => {
|
||||
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
|
||||
expect(screen.getByText('已中断')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an interrupted turn reads as stopped, not cancelled', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(screen.queryByText('已取消')).toBeNull()
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('other tool errors keep the generic summary with the error state', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('window-truncated result (call head lost) falls back to the callId summary', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
|
||||
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
)
|
||||
})
|
||||
})
|
||||
272
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
272
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
|
||||
* machinery, real renderer; data fed as fixtures) for surfaces that were
|
||||
* previously pinned only by the assembled-app jsdom snapshots
|
||||
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
|
||||
*
|
||||
* - the todo_write turn reaches BOTH surfaces through the product
|
||||
* registrations (keyed toolview row in the flow, plan strip in the input
|
||||
* dock via the 'todos' projection) and the strip follows projection
|
||||
* retirement;
|
||||
* - the bash keyed row carries its resident terminal card, and the fallback
|
||||
* row reaches the same card through its expand control;
|
||||
* - the resident composer textarea survives the blank→active conversion as
|
||||
* the SAME DOM node (focus/IME continuity rides React reconciliation:
|
||||
* component identity + tree position, which this assembled tree pins).
|
||||
*
|
||||
* Component-level behavior (collapse interaction, card model arms, summary
|
||||
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
|
||||
* suite only proves the assembled wiring.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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, 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. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const TODOS: TodoItem[] = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
|
||||
const todoResult = (seq: number): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
}
|
||||
|
||||
const LAYOUT_CHILDREN = {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
snapshot: {
|
||||
nodes,
|
||||
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
|
||||
},
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
|
||||
const runtime = await bench([todoResult(3)])
|
||||
// The dock strip reads the host-computed 'todos' projection.
|
||||
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-tool="todo_write"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
|
||||
// The plan strip sits in the input dock, fed by the projection
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
// Next turn retires the standing plan (host pushes null): the strip
|
||||
// clears while the historical row stays in the flow.
|
||||
await runtime.flush()
|
||||
runtime.sessions.behavior(SID).projections.set('todos', null)
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
})
|
||||
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal card assembly', () => {
|
||||
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash"]')
|
||||
const keyed = keyedRow?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(keyedRow!)
|
||||
await waitFor(() => {
|
||||
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Fallback row: same unified expand interaction.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
||||
await waitFor(() => {
|
||||
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
// No session entity: the inert twin renders (disabled textarea), and the
|
||||
// workspace picker chip is the only live control.
|
||||
const textarea = view.container.querySelector('textarea')
|
||||
expect(textarea).not.toBeNull()
|
||||
expect(textarea!.disabled).toBe(true)
|
||||
expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||
const runtime = await bench([], { blank: true })
|
||||
// The hero renders the LIVE composer only when the blank session's
|
||||
// workspace resolves a chip title; an ownerless blank session shows the
|
||||
// disabled twin instead (deleted-workspace semantics).
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||
})
|
||||
const view = runtime.renderRoot()
|
||||
const hero = view.container.querySelector('textarea')
|
||||
expect(hero).not.toBeNull()
|
||||
expect(hero!.disabled).toBe(false)
|
||||
|
||||
// First acceptance: the session leaves blank and the composer docks.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.blank = false
|
||||
draft.composerPhase = 'active'
|
||||
})
|
||||
const docked = view.container.querySelector('textarea')
|
||||
expect(docked).toBe(hero)
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
const prompt = vi.fn<ISession['prompt']>(async () => ({
|
||||
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||
}))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
const composer = view.container.querySelector('textarea')!
|
||||
fireEvent.change(composer, { target: { value: 'do not lose this' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
|
||||
|
||||
// The rejection lands in snapshot.promptError (the Session's own path);
|
||||
// the fixture mirrors that hop — the assembled InputBar renders it.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.promptError = {
|
||||
op: 'send',
|
||||
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||
}
|
||||
})
|
||||
const alert = await view.findByRole('alert')
|
||||
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
|
||||
// Failure restore: the machine returned the draft to the same textarea.
|
||||
await waitFor(() => {
|
||||
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title projection across assembled surfaces', () => {
|
||||
it('one summary update re-labels the current-session crumb', async () => {
|
||||
const runtime = await bench([])
|
||||
const view = runtime.renderRoot()
|
||||
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(within(hierarchy).getByRole('button', { name: '修订标题' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
expect(within(hierarchy).queryByRole('button', { name: 'S' })).toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -9,10 +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
|
||||
|
||||
@@ -22,12 +28,16 @@ async function bench() {
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
// Declared by ui-layout's root entry in production; the test root declares
|
||||
// them here so the contributions land.
|
||||
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 })
|
||||
@@ -50,7 +60,8 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
// Label is a locale thunk resolving through the zh dictionary.
|
||||
expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
@@ -75,15 +86,21 @@ 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 and the todo row 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()
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
// 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
|
||||
// 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', 'todo_write'])
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -97,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()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// Remaining chat branch tails: MessageItem context/unknown arms,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
@@ -8,17 +8,26 @@
|
||||
import { afterEach, beforeEach, 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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
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,
|
||||
@@ -27,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 node={{
|
||||
<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', () => {
|
||||
@@ -54,7 +67,7 @@ describe('MessageItem arms', () => {
|
||||
value: exec,
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
source: null,
|
||||
@@ -65,19 +78,37 @@ describe('MessageItem arms', () => {
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
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 never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
source: null,
|
||||
@@ -85,53 +116,334 @@ describe('MessageItem arms', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
fireEvent.click(copy)
|
||||
fireEvent.click(copy)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||
// lands the success chrome.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const done = screen.getByRole('button', { name: '复制成功' })
|
||||
fireEvent.click(done)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears copy feedback work when the message unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishWrite!: () => void
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
finishWrite()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const mounted = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 2, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
mounted.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
source: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
)
|
||||
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)
|
||||
const view = render(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 2_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const details = view.container.querySelector('details')
|
||||
const summary = view.container.querySelector('summary')
|
||||
expect(details?.open).toBe(false)
|
||||
expect(details?.dataset.active).toBe('true')
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
|
||||
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
|
||||
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1_100) })
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s')
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'scheduled',
|
||||
turn: 2,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s')
|
||||
|
||||
if (summary === null) throw new Error('retry summary missing')
|
||||
fireEvent.click(summary)
|
||||
expect(details?.open).toBe(true)
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
turn: 2,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(details?.dataset.active).toBeUndefined()
|
||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 7,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
turn: 3,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'always',
|
||||
policyKey: 'mock-always',
|
||||
retry: 3,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '继续重试' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 8,
|
||||
time: 12_100,
|
||||
retryState: 'cancelled',
|
||||
turn: 4,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '用户取消' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
|
||||
})
|
||||
|
||||
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
const node = {
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 5_000,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
} as const
|
||||
const view = render(<MessageItem t={t} node={node} />)
|
||||
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4_200) })
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
const now = new Date(2026, 6, 29, 10, 0).getTime()
|
||||
|
||||
it('keeps HH:mm on the same calendar day', () => {
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
|
||||
})
|
||||
|
||||
it('prefixes month and day across days in the same year', () => {
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
|
||||
})
|
||||
|
||||
it('prefixes year, month, and day across years', () => {
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
|
||||
})
|
||||
|
||||
it('arms the next local midnight from an in-day instant', () => {
|
||||
@@ -154,7 +466,7 @@ describe('useCalendarDay boundary refresh', () => {
|
||||
vi.setSystemTime(dayStart)
|
||||
const time = new Date(2026, 6, 29, 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||||
source: null,
|
||||
@@ -172,12 +484,12 @@ describe('useCalendarDay boundary refresh', () => {
|
||||
describe('small branch tails', () => {
|
||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
)
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -185,11 +497,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()
|
||||
@@ -197,25 +513,44 @@ 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(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(thinkOnly.queryByText('14:24')).toBeNull()
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
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.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,14 +17,26 @@ import type {
|
||||
ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
|
||||
@@ -55,10 +67,10 @@ function snapshotWith(
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
sessionId: SID, nodes, turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +90,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: 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() }
|
||||
@@ -116,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
@@ -125,7 +137,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
slots.installLocale(locale)
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
@@ -167,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()
|
||||
@@ -191,7 +205,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
@@ -199,8 +213,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
const view = mountApp(b.slots)
|
||||
// The code row is expandable via its leading control (body = the program).
|
||||
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
|
||||
// The code row is expandable via the whole summary row (body = the program).
|
||||
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
|
||||
expect(toggle).not.toBeNull()
|
||||
fireEvent.click(toggle!)
|
||||
// Shiki splits the program into token spans inside one <pre class="shiki">:
|
||||
@@ -250,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 () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (rendered inside the chat view body): 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).
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// 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'
|
||||
@@ -11,9 +10,16 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -26,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: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +57,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
describe('deriveStats', () => {
|
||||
it('folds turns/steps/tokens 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 }),
|
||||
@@ -59,37 +65,154 @@ describe('deriveStats', () => {
|
||||
])
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
expect(stats.tokens).toBe(1200)
|
||||
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; non-assistant nodes 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,
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
expect(stats.cacheHitPct).toBeNull()
|
||||
expect(stats.toolMs).toBe(0)
|
||||
})
|
||||
|
||||
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
|
||||
const timed: AssistantMessageNode = {
|
||||
...assistant(1, 1),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 },
|
||||
}
|
||||
const untimed: AssistantMessageNode = {
|
||||
...assistant(2, 1),
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 },
|
||||
}
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([timed, untimed, tool])
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
expect(stats.toolMs).toBe(3_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatters', () => {
|
||||
it('formats token counts compactly', () => {
|
||||
expect(formatTokens(517)).toBe('517')
|
||||
expect(formatTokens(12_240)).toBe('12.2K')
|
||||
expect(formatTokens(517_000)).toBe('517K')
|
||||
expect(formatTokens(1_230_000)).toBe('1.2M')
|
||||
})
|
||||
|
||||
it('formats durations under and over a minute', () => {
|
||||
expect(formatDuration(45_230)).toBe('45.2s')
|
||||
expect(formatDuration(162_000)).toBe('2m42s')
|
||||
})
|
||||
})
|
||||
|
||||
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 joined 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)} />)
|
||||
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
// 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
|
||||
@@ -108,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,
|
||||
@@ -118,67 +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, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
}): ToolRowProps => ({
|
||||
const rowProps = (): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ beforeEach(() => {
|
||||
describe('createChatStore', () => {
|
||||
it('init shape: empty selection/draft/view', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
|
||||
})
|
||||
|
||||
it('actions cover the declared write set', () => {
|
||||
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
|
||||
|
||||
store.actions.setView('chat')
|
||||
expect(store.store.getSnapshot().view).toBe('chat')
|
||||
|
||||
store.actions.setInspect({ callId: 'c1' })
|
||||
expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
|
||||
store.actions.setInspect(null)
|
||||
expect(store.store.getSnapshot().inspect).toBeNull()
|
||||
})
|
||||
|
||||
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
let nextAnimationFrameId = 1
|
||||
let animationFrames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
function flushAnimationFrames(count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const callbacks = [...animationFrames.values()]
|
||||
animationFrames.clear()
|
||||
for (const callback of callbacks) callback(index)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nextAnimationFrameId = 1
|
||||
animationFrames = new Map()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId
|
||||
nextAnimationFrameId += 1
|
||||
animationFrames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
animationFrames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
@@ -25,6 +59,7 @@ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
describe('tool-call-model', () => {
|
||||
it('classifies known tools and falls back to others', () => {
|
||||
expect(classifyTool('bash')).toBe('bash')
|
||||
expect(classifyTool('pwsh')).toBe('bash')
|
||||
expect(classifyTool('read')).toBe('read')
|
||||
expect(classifyTool('web_fetch')).toBe('read')
|
||||
expect(classifyTool('web_search')).toBe('search')
|
||||
@@ -37,6 +72,12 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
it('gives the pwsh shell row the bash family treatment with its own title', () => {
|
||||
const m = toolRowModel('pwsh', running())
|
||||
expect(m.variant).toBe('bash')
|
||||
expect(m.title).toBe('Pwsh')
|
||||
})
|
||||
|
||||
it('derives state across running/ok/error/interrupted', () => {
|
||||
expect(toolRowModel('bash', running()).state).toBe('running')
|
||||
expect(toolRowModel('bash', result()).state).toBe('ok')
|
||||
@@ -102,6 +143,29 @@ describe('tool-call-model', () => {
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
|
||||
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
|
||||
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
|
||||
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
|
||||
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
|
||||
.toBe('ToolError: denied')
|
||||
expect(resultText(result({ content: [] }))).toBe('')
|
||||
})
|
||||
|
||||
it('derives output from the settled result and null while running or blank', () => {
|
||||
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
|
||||
expect(toolRowModel('bash', running()).output).toBeNull()
|
||||
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
|
||||
})
|
||||
|
||||
it('derives errorSummary as the first output line on error rows only', () => {
|
||||
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
|
||||
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
|
||||
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
|
||||
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
|
||||
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -132,6 +196,7 @@ describe('tool-call-model', () => {
|
||||
|
||||
describe('ToolRow', () => {
|
||||
const rowProps = {
|
||||
t,
|
||||
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
|
||||
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
|
||||
}
|
||||
@@ -144,14 +209,15 @@ describe('ToolRow', () => {
|
||||
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
|
||||
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
@@ -162,16 +228,20 @@ describe('ToolRow', () => {
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
||||
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
|
||||
// The dot rides the idle slot, so an expandable error row keeps the
|
||||
// icon→chevron hover preview instead of losing it with the icon.
|
||||
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('non-expandable rows render a passive leading slot', () => {
|
||||
it('non-expandable rows render a passive leading slot and no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} />)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
it('the row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
@@ -181,32 +251,31 @@ describe('ToolRow', () => {
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
it('file rows expand from the row while the path link opens without toggling', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
||||
)
|
||||
const row = view.getByRole('button', { name: /Read/ })
|
||||
// Path click opens the file and leaves the row collapsed.
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(open).toHaveBeenCalledWith('src/a.ts')
|
||||
// Only the path link is a button — no args-expand affordance on file rows.
|
||||
expect(view.container.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
// Row click (outside the link) expands the args body.
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a single-file path disables expand even when onOpenFile is absent', () => {
|
||||
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
||||
)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
fireEvent.click(view.getByText('作文.md'))
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('non-file rows do not open anything when the summary is clicked', () => {
|
||||
@@ -215,12 +284,123 @@ describe('ToolRow', () => {
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
|
||||
)
|
||||
expect(view.getByText('boom')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.getByText(/detail/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-error]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an error row without an error summary keeps the args summary', () => {
|
||||
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
<ToolRow
|
||||
{...rowProps}
|
||||
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
|
||||
filePath="src/a.ts" onOpenFile={open}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByText('cannot overwrite'))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
// The failure line renders as plain text, not the underlined link button.
|
||||
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
|
||||
const inspect = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
|
||||
// Collapsed: no pill.
|
||||
expect(view.queryByText('Inspect')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
const pill = view.getByText('Inspect')
|
||||
fireEvent.click(pill)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
// The pill click must not collapse the row (body is a .row sibling).
|
||||
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('no inspect callback, no pill', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByText('Inspect')).toBeNull()
|
||||
})
|
||||
|
||||
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
|
||||
const both = render(<ToolRow {...rowProps} output="result text" />)
|
||||
fireEvent.click(both.getByRole('button'))
|
||||
expect(both.getByText('IN')).toBeTruthy()
|
||||
expect(both.getByText('OUT')).toBeTruthy()
|
||||
expect(both.getByText('result text')).toBeTruthy()
|
||||
cleanup()
|
||||
const inputOnly = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(inputOnly.getByRole('button'))
|
||||
expect(inputOnly.getByText('IN')).toBeTruthy()
|
||||
expect(inputOnly.queryByText('OUT')).toBeNull()
|
||||
cleanup()
|
||||
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
|
||||
fireEvent.click(outputOnly.getByRole('button'))
|
||||
expect(outputOnly.queryByText('IN')).toBeNull()
|
||||
expect(outputOnly.getByText('OUT')).toBeTruthy()
|
||||
expect(outputOnly.getByText('only out')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThinkRow', () => {
|
||||
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(0)
|
||||
flushAnimationFrames(2)
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
flushAnimationFrames(1)
|
||||
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}
|
||||
/>,
|
||||
)
|
||||
flushAnimationFrames(3)
|
||||
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
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
@@ -234,11 +414,27 @@ describe('ThinkRow', () => {
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
// The summary (first line) is gone from the row; only the body carries it.
|
||||
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
|
||||
expect(view.queryByText('IN')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(),
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
@@ -283,6 +479,14 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('passes the owner inspect callback through to the expanded row pill', () => {
|
||||
const inspect = vi.fn()
|
||||
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('file-path summary click reaches openFile; bash summary does not', () => {
|
||||
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
||||
const fileView = render(<GenericToolCard {...file} />)
|
||||
|
||||
@@ -15,15 +15,27 @@ import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import type { ISession, SessionId, 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 { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
@@ -53,6 +65,9 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layout)
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
@@ -76,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.
|
||||
@@ -99,7 +114,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
@@ -180,6 +195,9 @@ describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, 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 { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
// Keyless create() persists under the bare declared key; clear between cases
|
||||
// so one harness's selection cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
@@ -29,9 +37,9 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +69,20 @@ const user = (seq: number, text: string): UserMessageNode => ({
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const retry = (seq: number): ModelRetryNode => ({
|
||||
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
retryState: 'scheduled',
|
||||
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
|
||||
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,
|
||||
@@ -77,13 +97,13 @@ 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)
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
@@ -94,6 +114,14 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const inspectCall = vi.fn<(callId: string) => void>()
|
||||
// In-memory scroll memory matching the apply.ts per-session map contract.
|
||||
let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
|
||||
const chatScroll: ChatViewSlotProps['chatScroll'] = {
|
||||
save: (position) => { savedScroll = position },
|
||||
read: () => savedScroll,
|
||||
}
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
@@ -120,9 +148,40 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
/** Simulate reader input before the browser delivers the host scroll event. */
|
||||
function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
|
||||
element.scrollTop = top
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
|
||||
let scrollHeight = initialHeight
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
|
||||
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
|
||||
})
|
||||
return {
|
||||
setHeight: (value: number) => { scrollHeight = value },
|
||||
setLayout: (height: number, top: number) => {
|
||||
scrollHeight = height
|
||||
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -139,6 +198,17 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
|
||||
it('reuses one stable row for consecutive retry turns', () => {
|
||||
const first = retry(2)
|
||||
const second = { ...retry(3), turn: 2, retry: 2 }
|
||||
const initial = deriveChatFlow([user(1, 'try'), first])
|
||||
const updated = deriveChatFlow([user(1, 'try'), first, second])
|
||||
expect(flowKeys(initial)).toBe('n1|n2')
|
||||
expect(flowKeys(updated)).toBe('n1|n2')
|
||||
expect(updated).toHaveLength(2)
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
@@ -154,6 +224,61 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const seqs = assistantActionsSeqs([
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
toolResult(4, 'a'),
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
])
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
expect(formatRunDuration(-500, t)).toBe('0秒')
|
||||
expect(formatRunDuration(15_999, t)).toBe('15秒')
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
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'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7, 10, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -167,20 +292,36 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('w1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
|
||||
// Covers the prepend early-return arm where lastItem exists but the key
|
||||
// path is not taken (anchor branch wins before the appended-user check).
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
|
||||
let firstTop = 100
|
||||
let nextTop = 300
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: 0, bottom: 200 } as DOMRect),
|
||||
)
|
||||
vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
|
||||
)
|
||||
vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
|
||||
)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 50
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// The reader moves after the request starts; this, not the click-time
|
||||
// row, is the intent the arriving page must preserve.
|
||||
firstTop = -200
|
||||
nextTop = 60
|
||||
readerScroll(scroller, 90)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||
nextTop = 560
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
|
||||
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
|
||||
})
|
||||
|
||||
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
|
||||
@@ -192,6 +333,258 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('running tools')).toBeTruthy()
|
||||
expect(view.getAllByText('Bash')).toHaveLength(2)
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
|
||||
key: row.getAttribute('data-chat-flow-key'),
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
}))).toEqual([
|
||||
{ key: 'n1', kind: 'user' },
|
||||
{ key: 'n2', kind: 'assistant' },
|
||||
{ key: 'g3', kind: 'tool-group' },
|
||||
])
|
||||
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
|
||||
.toEqual(['a', 'b'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
|
||||
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
|
||||
})
|
||||
|
||||
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: 'user', seq: 2, time: 2_000,
|
||||
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: 'user', seq: 2, time: 2_000,
|
||||
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 }
|
||||
const context = {
|
||||
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const disclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||
expect(disclosure.dataset.active).toBe('true')
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
||||
})
|
||||
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
|
||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, 'try'),
|
||||
retryNode,
|
||||
{ ...nextRetry, retryState: 'started' },
|
||||
context,
|
||||
assistant(5, 'done'),
|
||||
],
|
||||
running: false,
|
||||
})
|
||||
})
|
||||
expect(disclosure.dataset.active).toBeUndefined()
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
||||
})
|
||||
const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||
expect(cancelledDisclosure.dataset.active).toBeUndefined()
|
||||
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')],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(h.inspectCall).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('shows assistant IconActions only on the last content message of each turn', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'mid-turn text'),
|
||||
toolResult(3, 'a'),
|
||||
assistant(4, 'final answer'),
|
||||
user(5, 'next'),
|
||||
assistant(6, 'second turn', 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 4], [2, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Every message footer keeps branch visible; only completed assistant tails enable it.
|
||||
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('the actions-owning assistant footer shows the turn run time', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'),
|
||||
toolResult(18, 'trailing'),
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The exact turn/end includes trailing tool activity after the final text.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('the run-time label is withheld when the turn start is outside the window', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [assistant(16, 'tail without trigger')],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/用时/)).toBeNull()
|
||||
})
|
||||
|
||||
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([[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', () => {
|
||||
@@ -280,11 +673,11 @@ describe('ChatView', () => {
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('tool row expands to the args body via the leading slot toggle', () => {
|
||||
it('tool row expands to the args body via the whole-row toggle', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -318,6 +711,31 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(view.getByText('cmd-r1')).toBeTruthy()
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
const h = makeHarness({
|
||||
nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Freshly mounted (as after a reload) yet already past the 15s gate.
|
||||
const status = view.getByRole('status')
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => {
|
||||
h.set({ queue: [{
|
||||
id: 'steering-occurrence' as never,
|
||||
messageId: 'steering-message' as never,
|
||||
placement: 'steering',
|
||||
content: [{ type: 'text', text: 'also' }],
|
||||
preview: 'also',
|
||||
text: 'also',
|
||||
}] })
|
||||
})
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
@@ -335,31 +753,106 @@ describe('ChatView', () => {
|
||||
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
|
||||
})
|
||||
|
||||
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
|
||||
let anchoredTop = 100
|
||||
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
)
|
||||
readerScroll(scroller, 80)
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
anchoredTop = 700
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'call:late') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
|
||||
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:5') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(1_300)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 100 // far from bottom
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 100) // far from bottom
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
@@ -371,6 +864,197 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// The wheel cannot move farther down. A stream-finalization shrink clamps
|
||||
// the old position, then reflow grows the layout before scroll delivery.
|
||||
fireEvent.wheel(scroller, { deltaY: 120 })
|
||||
metrics.setLayout(1_040, 500)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(740)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { h.set({ running: true }) })
|
||||
expect(scroller.scrollTop).toBe(900)
|
||||
})
|
||||
|
||||
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.wheel(scroller, { deltaY: -200 })
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
|
||||
let notify: (() => void) | undefined
|
||||
const observe = vi.fn()
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
notify = () => { callback([], this as unknown as ResizeObserver) }
|
||||
}
|
||||
|
||||
observe = observe
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(1_200)
|
||||
readerScroll(scroller, 200)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(200)
|
||||
expect(observe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
readerScroll(scroller, 690) // distance-to-bottom = 10
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
|
||||
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
readerScroll(host, 100)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount restores the saved semantic row after width reflow', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
let anchorTop = 80
|
||||
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: 0, bottom: 500 } as DOMRect),
|
||||
)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') {
|
||||
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 40 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
// Fresh open (nothing saved): the bottom jump stands.
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
// Reader scrolls up; the position is recorded continuously.
|
||||
readerScroll(host, 100)
|
||||
// View-tab switch away and back: the view unmounts, then remounts.
|
||||
view.rerender(<div />)
|
||||
anchorTop = 560
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
|
||||
// The restored position is above the floor: follow stays disarmed.
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(host, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = Math.min(value, 1_500) },
|
||||
})
|
||||
document.body.appendChild(host)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
return { top: 0, bottom: 500 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount while pinned to the bottom keeps the bottom jump', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// At the bottom: the scroll event records the pinned state (null).
|
||||
fireEvent.scroll(host)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -412,10 +1096,14 @@ describe('ChatView', () => {
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
// Settled success: the command line is the title, the outcome text the summary.
|
||||
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
|
||||
// Settled success: the bare command name is the title, the outcome text
|
||||
// the summary — neither the dispatched `/` nor its arguments reach the row
|
||||
// (the settlement text already says what the command did).
|
||||
const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
|
||||
const view = render(<settled.ChatView {...settled.props} />)
|
||||
expect(view.getByText('/plan')).toBeTruthy()
|
||||
expect(view.getByText('plan')).toBeTruthy()
|
||||
expect(view.queryByText('/plan')).toBeNull()
|
||||
expect(view.queryByText('/plan now')).toBeNull()
|
||||
expect(view.getByText('已进入 plan mode')).toBeTruthy()
|
||||
|
||||
// Error outcome flips the row state; a text-less error gets the default copy.
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
|
||||
// reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } 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 { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -24,7 +31,7 @@ describe('tails', () => {
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
const view = render(
|
||||
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
)
|
||||
expect(view.queryByTestId('icon')).toBeNull()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
@@ -33,6 +40,7 @@ describe('tails', () => {
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
|
||||
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
|
||||
@@ -45,7 +53,7 @@ describe('tails', () => {
|
||||
expect(view.getByText('thinking hard')).toBeTruthy()
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
const stopped = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
|
||||
)
|
||||
expect(stopped.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
@@ -55,12 +63,13 @@ describe('tails', () => {
|
||||
// groups is layout noise (no text, no pulse, no interrupted marker).
|
||||
const empty = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
|
||||
const blank = render(<AssistantMarkdown t={t} blocks={[]} streaming={false} />)
|
||||
expect(blank.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
@@ -71,8 +80,8 @@ describe('tails', () => {
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
|
||||
const props: GenericToolCardProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -80,18 +89,21 @@ 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 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: 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(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
@@ -115,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()
|
||||
|
||||
370
packages/client/ui-conversation/tests/diff-card.spec.tsx
Normal file
370
packages/client/ui-conversation/tests/diff-card.spec.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
// @vitest-environment jsdom
|
||||
// The diff render intent on the web side: the pure diffCardModel derivation
|
||||
// over callView/resultView, and both conversation render sites that consume it
|
||||
// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and
|
||||
// the details panel's Output section.
|
||||
|
||||
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 { ToolCallView, 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_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx'
|
||||
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)
|
||||
|
||||
const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
|
||||
|
||||
/** The edit tool's own call view (a call-time diff derived from the arguments). */
|
||||
const callDiff = (over?: Partial<Extract<ToolCallView, { card: 'diff' }>>): ToolCallView => ({
|
||||
card: 'diff', title: 'Edit notes/demo.txt',
|
||||
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
|
||||
})
|
||||
|
||||
/** The edit tool's own result view (the applied hunk diff). */
|
||||
const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>): ToolResultView => ({
|
||||
card: 'diff', title: 'Edit notes/demo.txt',
|
||||
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'edit', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'edit', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
|
||||
callView: callDiff(), resultView: resultDiff(), ...over,
|
||||
})
|
||||
|
||||
describe('diffCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(diffCardModel(running())).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from the result view, which replaces the call-time diff', () => {
|
||||
// The applied hunks (result) win over the args-derived call diff.
|
||||
expect(diffCardModel(settled({
|
||||
resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }),
|
||||
}))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a settled diff even when the window dropped the call head', () => {
|
||||
// A truncated call carries only the result view, which holds the whole change.
|
||||
expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns null for every non-diff call: no views, generic views, unknown cards', () => {
|
||||
expect(diffCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a diff call on the generic path (write/edit's
|
||||
// own execution-error arm).
|
||||
expect(diffCardModel(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', title: 'plot' } as unknown as ToolCallView
|
||||
expect(diffCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(diffCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to null for a malformed diff payload off the wire', () => {
|
||||
// toolEventViewSchema validates only the `card` string, so a version
|
||||
// mismatch can deliver a diff card with an unusable diffs field. Each shape
|
||||
// routes to the generic path instead of throwing inside DiffBlock.
|
||||
const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView)
|
||||
expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull()
|
||||
// The running side narrows identically.
|
||||
expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row diff body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('the expanded body is the applied diff, capped tighter than the panel', () => {
|
||||
expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the summary row (path) only, no diff body.
|
||||
expect(view.queryByText('hello fixture')).toBeNull()
|
||||
// The path link is not the expand control; the leading toggle is.
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
|
||||
expect(view.getByText('hello fixture')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running diff call expands to its intended change', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a non-diff call keeps the args-JSON text body', () => {
|
||||
// A non-file tool name so the row is not single-file (no path link), and its
|
||||
// args body is the fallback the diff card must not have replaced.
|
||||
const view = render(<GenericToolCard {...{
|
||||
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
|
||||
block: settled({
|
||||
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
|
||||
callView: null, resultView: null,
|
||||
}),
|
||||
}} />)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
expect(view.getByText(/"foo"/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileMutationRow diff card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
t,
|
||||
} as unknown as FileMutationRowProps)
|
||||
|
||||
/** 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 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()
|
||||
})
|
||||
|
||||
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.
|
||||
expect(openFile).toHaveBeenCalledWith('notes/demo.txt')
|
||||
})
|
||||
|
||||
it('registers under write too, rendering a create as an added-only diff', () => {
|
||||
const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}'
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
call: { name: 'write', argsRaw: writeArgs },
|
||||
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()
|
||||
})
|
||||
|
||||
it('reflects the run state on its leading slot', () => {
|
||||
const runningView = render(<FileMutationRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
cleanup()
|
||||
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true, resultView: null, callView: null }))} />)
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
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 — 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' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the error name/code when an errored result has no text block', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null, content: [],
|
||||
error: { name: 'ToolError', code: 'sandbox_denied' },
|
||||
}))} />)
|
||||
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
|
||||
})
|
||||
|
||||
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*="_errorSummary_"]')).toBeNull()
|
||||
cleanup()
|
||||
const run = render(<FileMutationRow {...rowProps(running())} />)
|
||||
expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the stopped state when the call was interrupted', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
// 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()
|
||||
})
|
||||
|
||||
it('renders a plain summary span when the call carries no file path', () => {
|
||||
// Empty args leave deriveFilePath undefined, so the summary is not a link.
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
call: { name: 'edit', argsRaw: '' }, callView: null, resultView: null,
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileMutationToolview registration', () => {
|
||||
it('registers one component under both edit and write, and each disposes', () => {
|
||||
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
|
||||
const disposers: (() => void)[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
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)
|
||||
return dispose
|
||||
},
|
||||
},
|
||||
}
|
||||
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).
|
||||
for (const dispose of disposers) dispose()
|
||||
expect(registered.every(r => r.disposed)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel diff Output section', () => {
|
||||
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, 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}
|
||||
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: [], turnTimings: new Map(), 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: 'edit' }
|
||||
|
||||
it('renders the applied diff at full height, keeping the JSON Input section', () => {
|
||||
const view = mount(snapshot({ nodes: [settled()] }), target)
|
||||
expect(view.getByText(/"file_path"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
|
||||
expect(view.getByText('hello fixture')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running diff call renders its intended change, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
})
|
||||
|
||||
it('a non-diff result keeps the flattened pre', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,16 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/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 { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -18,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: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +34,7 @@ describe('render branch tails', () => {
|
||||
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
|
||||
streaming
|
||||
/>,
|
||||
@@ -36,25 +43,29 @@ 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.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
expect(view.container.textContent).toBe('2 turns · 3 steps')
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
|
||||
)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
@@ -65,9 +76,9 @@ 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: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
@@ -82,6 +93,7 @@ describe('render branch tails', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
@@ -101,9 +113,9 @@ 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: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
@@ -118,6 +130,7 @@ describe('render branch tails', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
// @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.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, 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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
|
||||
// is absent — and the composer measures the caret with one when it restores the
|
||||
// selection after an edit it performed itself. Every case here runs against a
|
||||
// zero rect; the reveal case below substitutes its own and restores this one.
|
||||
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
|
||||
// Read through the descriptor so the native method is never referenced unbound;
|
||||
// the reveal case below wraps it to record what it was asked to measure.
|
||||
const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
|
||||
.value as (this: Range, node: Node, offset: number) => void
|
||||
|
||||
const SCTX = {} as ClientContext
|
||||
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: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
@@ -38,14 +53,20 @@ 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'
|
||||
placeholder?: string
|
||||
t?: InputBarProps['t']
|
||||
command?: (line: string) => Promise<boolean>
|
||||
accessory?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
rightItems?: React.ReactNode
|
||||
commandMenuOpen?: boolean
|
||||
busyEnter?: 'queue' | 'steer'
|
||||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||||
}
|
||||
|
||||
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
||||
@@ -69,10 +90,12 @@ 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,
|
||||
}))
|
||||
const stop = vi.fn()
|
||||
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
||||
const slotCalls: { key: string; owner: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, owner })
|
||||
@@ -86,9 +109,10 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
@@ -96,10 +120,19 @@ 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),
|
||||
useMenuLauncher: bindSnapshotSelector(menuLauncher),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
command: over?.command ?? (() => Promise.resolve(true)),
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: over?.t ?? makeTranslate(zh, commonZh),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -110,11 +143,11 @@ 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 ? 'Stop generating' : 'Send message'}"]`,
|
||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
|
||||
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
|
||||
}
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
@@ -143,12 +176,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', () => {
|
||||
@@ -190,16 +229,82 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
||||
expect(button.getAttribute('aria-label')).toBe('Stop generating')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
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)
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
@@ -216,7 +321,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const textarea = first.view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
|
||||
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
@@ -227,24 +332,222 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
||||
expect(wheeled).toBe(false) // preventDefault
|
||||
expect(host.scrollTop).toBe(70)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(scrollport, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
try {
|
||||
// Mid-draft: both directions stay local — host must not move.
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
||||
expect(host.scrollTop).toBe(40)
|
||||
// At the bottom edge, further down-scroll forwards to the host.
|
||||
scrollTop = 300
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(70)
|
||||
// At the top edge, further up-scroll forwards to the host.
|
||||
scrollTop = 0
|
||||
host.scrollTop = 70
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(50)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('the caret layer and the glyph layer ride one scrollport', () => {
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
// The caret is the textarea's and every visible glyph is the backdrop's, so
|
||||
// one box has to carry both or an offset can exist in one and not the other.
|
||||
// jsdom has no layout and loads no stylesheet — which box scrolls is the
|
||||
// browser scenario's to assert; what is checkable here is that the
|
||||
// scrollport element holds both layers.
|
||||
expect(scroll.contains(textarea)).toBe(true)
|
||||
expect(scroll.contains(backdrop)).toBe(true)
|
||||
// The glyph layer carries the draft and nothing else: with one scrollport
|
||||
// it no longer pads its own height to match a second box's scroll extent.
|
||||
expect(backdrop.textContent).toBe('line\n'.repeat(40))
|
||||
})
|
||||
|
||||
it('an edit the composer performs itself scrolls the caret back into view', async () => {
|
||||
// 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) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
expect(mirror.firstChild).toBeInstanceOf(Text)
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
// jsdom reports scrollHeight === clientHeight for every element, which is
|
||||
// the composer's own "nothing to reveal" case; a scrollable box is what
|
||||
// puts the reveal on the table at all.
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
onTestFinished(() => {
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
Range.prototype.setStart = NATIVE_SET_START
|
||||
})
|
||||
// Which layer the caret is measured against, and at which index: the stub
|
||||
// records `setStart` so a helper that measured the backdrop instead, or
|
||||
// always collapsed at 0, fails here rather than only in the browser lane.
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const caretAt = (top: number): void => {
|
||||
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
|
||||
}
|
||||
const settle = async (): Promise<void> => {
|
||||
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
|
||||
}
|
||||
// Pasted text lands below the fold: scroll down by exactly the overshoot.
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88) // 524 - 436
|
||||
// Measured on the mirror's own text, at the index the paste left the caret
|
||||
// (an empty draft's selection start, 0, plus the pasted length).
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe('pasted'.length)
|
||||
// A caret already inside the box does not move it.
|
||||
caretAt(200)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88)
|
||||
// Above the fold (a cut can leave it there): scroll back up.
|
||||
caretAt(60)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
|
||||
// A caret straight after a newline has nothing on its line to measure, so
|
||||
// the newline it just left is measured instead and one line is added.
|
||||
// chromium reports no client rects at all for the collapsed position.
|
||||
mirror.style.lineHeight = '24px'
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
|
||||
await settle()
|
||||
// The four pastes accumulate at the draft's head, so the caret is at the
|
||||
// end of what they inserted — and the measured index is the newline before it.
|
||||
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
|
||||
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
|
||||
// The composer DOM is reused across sessions, so the previous session's
|
||||
// offset survives while the value swap puts the caret at the new draft's
|
||||
// end. `preventScroll` keeps the browser from revealing it through the
|
||||
// conversation scrollport, which leaves the reveal to the effect itself.
|
||||
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
// The draft ends in a newline, so the reveal takes the after-newline path
|
||||
// and needs a resolvable line-height (jsdom computes `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
// Which index the effect reveals at, not merely that it scrolled: a
|
||||
// revealCaret(0) would land the same offset without this.
|
||||
onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const focused: (boolean | undefined)[] = []
|
||||
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||||
expect(focused).toEqual([true])
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
// The draft ends in a newline, so the rule measures that newline: the
|
||||
// caret's own index is the mirror text's length minus its sentinel.
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe(textarea.value.length - 1)
|
||||
})
|
||||
|
||||
it('a persisted draft adopted after mount gets its caret revealed too', () => {
|
||||
// ConversationSession seeds the stored draft in its own mount effect, which
|
||||
// runs after this component's: the first reveal measures an empty mirror,
|
||||
// so the draft's arrival has to run it again without reclaiming focus.
|
||||
const { view, textarea, shell } = bench()
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
// The restored draft ends in a newline, so the reveal takes the
|
||||
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
const other = document.createElement('input')
|
||||
document.body.appendChild(other)
|
||||
onTestFinished(() => { other.remove() })
|
||||
other.focus()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
||||
expect(document.activeElement).toBe(other)
|
||||
// The caret the machine left at the draft's end, revealed once the draft exists.
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length)
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
const { textarea } = bench({ disabled: true })
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
const live = bench()
|
||||
expect(live.textarea.placeholder).toBe('Message the agent')
|
||||
expect(live.textarea.placeholder).toBe('给智能体发消息')
|
||||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
||||
const active = bench({ plan: { active: true, pending: false } })
|
||||
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
|
||||
// /plan just ran: pending entry already reads as the plan target.
|
||||
const entering = bench({ plan: { active: false, pending: true } })
|
||||
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
|
||||
// Pending exit: target is default again.
|
||||
const leaving = bench({ plan: { active: true, pending: true } })
|
||||
expect(leaving.textarea.placeholder).toBe('Message the agent')
|
||||
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
|
||||
// Owner placeholder outranks the plan swap.
|
||||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
@@ -264,18 +567,19 @@ 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')!
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations', () => {
|
||||
it('claimed token renders the mirror highlight and the blank-args hint', () => {
|
||||
const { view, shell } = bench()
|
||||
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
||||
const { view, shell } = bench({ t: makeTranslate({}) })
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
@@ -292,6 +596,18 @@ describe('decorations', () => {
|
||||
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
||||
const { view, shell } = bench()
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
})
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
})
|
||||
|
||||
it('an inserted reference renders as a chip at its placeholder offset', () => {
|
||||
const { view, shell } = bench()
|
||||
act(() => {
|
||||
@@ -369,19 +685,61 @@ describe('strips and variants', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder chrome and control seats', () => {
|
||||
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
describe('command launcher chrome and control seats', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect(view.getByLabelText('命令')).toBeTruthy()
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText('Access mode')).toBeNull()
|
||||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
||||
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
|
||||
const toggleCommandMenu = vi.fn()
|
||||
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
|
||||
textarea.setSelectionRange(2, 7)
|
||||
const launcher = view.getByLabelText('命令')
|
||||
expect(launcher.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(launcher)
|
||||
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
|
||||
act(() => { menuLauncher.set('command') })
|
||||
expect(launcher.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'read-only',
|
||||
}
|
||||
const { view } = bench({ permissions, command })
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Read Only')
|
||||
expect([...trigger.querySelectorAll('svg')]
|
||||
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
fireEvent.click(items[1]!)
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Workspace Write')
|
||||
expect(busy.disabled).toBe(true)
|
||||
expect(command).toHaveBeenCalledWith('/permission workspace-write')
|
||||
await act(async () => {})
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('requires explicit risk acknowledgement before submitting Full access', async () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
@@ -389,16 +747,86 @@ describe('placeholder chrome and control seats', () => {
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const select = view.getByLabelText('Access mode') as HTMLSelectElement
|
||||
expect(select.value).toBe('workspace-write')
|
||||
// Title-case display is presentation only; the option values stay machine names.
|
||||
expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
||||
fireEvent.change(select, { target: { value: 'danger-full-access' } })
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
expect(select.disabled).toBe(true)
|
||||
const { view } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
|
||||
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
|
||||
expect(enable.disabled).toBe(true)
|
||||
|
||||
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
|
||||
expect(enable.disabled).toBe(false)
|
||||
fireEvent.click(enable)
|
||||
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
|
||||
await act(async () => {})
|
||||
expect(select.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions, command })
|
||||
const openConfirmation = () => {
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
}
|
||||
|
||||
openConfirmation()
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
fireEvent.click(view.getByRole('button', { name: '取消' }))
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
|
||||
|
||||
openConfirmation()
|
||||
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
|
||||
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('revokes an open Full access confirmation when the task locks', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view, session } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
act(() => { session.set(snapshotOf({ removed: true })) })
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets an open Full access confirmation when switching tasks', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view, props } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
@@ -416,13 +844,13 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access chip and attach control (running does not)', () => {
|
||||
it('disabled locks the Access chip and command launcher (running does not)', () => {
|
||||
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
|
||||
const { view } = bench({ disabled: true, permissions })
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
|
||||
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }))
|
||||
@@ -260,10 +267,10 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
|
||||
expect(m.state.draft).toBe(`${P} and ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} and ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
|
||||
// Delete the first chip whole; the second survives with its own identity.
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
|
||||
})
|
||||
|
||||
@@ -273,7 +280,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
|
||||
expect(m.state.draft).toBe(`/goal ask ${P}`)
|
||||
expect(m.state.draft).toBe(`/goal ask ${P} `)
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -410,7 +392,7 @@ describe('input-machine: consume-token guards', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
|
||||
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -490,7 +472,7 @@ describe('input-machine: undo / redo', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([])
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -555,9 +537,9 @@ describe('input-machine: paste plane', () => {
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
|
||||
expect(m.state.draft).toBe(`${P} ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 })
|
||||
})
|
||||
|
||||
it('a stale span CAS drops one upgrade without ending the attempt', () => {
|
||||
@@ -634,8 +616,8 @@ describe('input-machine: projectClipboard', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P}`)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P} `)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
|
||||
})
|
||||
|
||||
it('is the identity on a chip-free draft', () => {
|
||||
|
||||
@@ -11,9 +11,12 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/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 { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -23,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: [], turnTimings: new Map(), 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,
|
||||
@@ -34,20 +37,26 @@ 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: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
@@ -91,7 +100,8 @@ describe('matrix row: claimed', () => {
|
||||
claim()
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||
// The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
expect((textarea).readOnly).toBe(false)
|
||||
// Free editing beyond the token: hint drops, claim holds.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
@@ -169,7 +179,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
const { view, textarea, shell } = bench({ disabled: true })
|
||||
expect((textarea).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -109,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: [], turnTimings: new Map(), 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,
|
||||
@@ -120,20 +123,34 @@ 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: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: (selection) => {
|
||||
const snapshot = shell.snapshot
|
||||
controller.toggleSource('command', {
|
||||
trigger: '/',
|
||||
query: '',
|
||||
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
|
||||
span: { ...selection, draftRev: snapshot.draftRev },
|
||||
})
|
||||
},
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
useMenuLauncher: bindSnapshotSelector(controller.launcher),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
@@ -167,7 +184,8 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
expect(b.textarea.value).toBe('/goal ')
|
||||
expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
|
||||
// The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
|
||||
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
// Continue typing args; hint drops; claim holds.
|
||||
b.type('/goal 发布 v1')
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
|
||||
@@ -1,56 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
|
||||
* nothing, rows render one preview line each keyed by rpcId, and the strip
|
||||
* follows queue changes through the useSession selector.
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* collapse state, removal, strict steering, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
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 { QueueItemId } from '../src/client/contract/queue.ts'
|
||||
import type { InputState } from '../src/client/input/contract.ts'
|
||||
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
const iid = (id: string): QueueItemId => id as QueueItemId
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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,
|
||||
id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
|
||||
content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
|
||||
preview, text,
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), 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, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal live source backing the useSession stub. */
|
||||
function liveSession(initial: ConversationSnapshot) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
|
||||
const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
|
||||
useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
(listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
() => sel(snapshot),
|
||||
() => selector(snapshot),
|
||||
)
|
||||
return {
|
||||
useSession,
|
||||
push(next: ConversationSnapshot): void {
|
||||
snapshot = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
|
||||
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
|
||||
|
||||
function kitFor(snapshot: ConversationSnapshot) {
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain.
|
||||
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
|
||||
return {
|
||||
sessionId: SID,
|
||||
t,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||
useProjection: (() => undefined) as never,
|
||||
@@ -58,6 +75,9 @@ function kitFor(snapshot: ConversationSnapshot) {
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
session: snapshot,
|
||||
input: INPUT_STATE,
|
||||
updateQueue: vi.fn(() => Promise.resolve()),
|
||||
notify: vi.fn(),
|
||||
...injected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,20 +89,282 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one preview row per queued message with the count strip', () => {
|
||||
const snap = snapshotWith([
|
||||
{ key: 'p-1', preview: '第一条排队消息' },
|
||||
{ key: 'p-2', preview: 'second queued line' },
|
||||
])
|
||||
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.textContent).toContain('已排队 2 条')
|
||||
const rows = [...container.querySelectorAll('li')]
|
||||
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('follows queue changes: retirement empties the strip back to null', () => {
|
||||
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
|
||||
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)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
expect(view.queryByText('two')).toBeNull()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
expect(view.getByText('two')).toBeTruthy()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an active single-row editor visible when another item arrives', () => {
|
||||
const single = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
|
||||
fireEvent.click(view.getByLabelText('编辑排队消息'))
|
||||
fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByLabelText('取消编辑'))
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('second')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an in-flight row action visible when another item arrives', async () => {
|
||||
const single = snapshotWith([row('i-remove', 'remove me')])
|
||||
const source = liveSession(single)
|
||||
let finishUpdate: (() => void) | undefined
|
||||
const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
|
||||
const view = render(
|
||||
<QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByLabelText('删除排队消息'))
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('remove me')).toBeTruthy()
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
act(() => { finishUpdate?.() })
|
||||
await waitFor(() => {
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
|
||||
const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(first)
|
||||
const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
|
||||
fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([])) })
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('three')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders active actions and disables editing for mixed-content rows', () => {
|
||||
const snap = snapshotWith([
|
||||
row('i-1', '第一条排队消息'),
|
||||
row('i-2', null, 'image [image]'),
|
||||
])
|
||||
const source = liveSession(snap)
|
||||
const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
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(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'))
|
||||
.toBe('包含非文本内容,暂不支持编辑')
|
||||
})
|
||||
|
||||
it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText, queryByLabelText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
|
||||
expect(getByLabelText('保存排队消息')).toBeTruthy()
|
||||
expect(getByLabelText('取消编辑')).toBeTruthy()
|
||||
expect(queryByLabelText('删除排队消息')).toBeNull()
|
||||
fireEvent.change(editor, { target: { value: 'after' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels an edit by button or Escape without mutating the queue', () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
|
||||
fireEvent.click(getByLabelText('取消编辑'))
|
||||
expect(getByText('before')).toBeTruthy()
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
|
||||
expect(getByText('before')).toBeTruthy()
|
||||
expect(updateQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps editing during IME composition and disables a blank save', () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
const editor = getByLabelText('编辑排队消息')
|
||||
fireEvent.change(editor, { target: { value: ' ' } })
|
||||
expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
|
||||
fireEvent.change(editor, { target: { value: '输入中' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
|
||||
expect(updateQueue).not.toHaveBeenCalled()
|
||||
expect(getByLabelText('编辑排队消息')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('removes the addressed row', async () => {
|
||||
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getAllByLabelText, getByRole } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
const notify = vi.fn()
|
||||
const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('删除排队消息'))
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
|
||||
})
|
||||
expect(getByText('pending')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('follows authoritative retirement back to null', () => {
|
||||
const snap = snapshotWith([row('i-1', '在场')])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.textContent).toContain('在场')
|
||||
@@ -90,11 +372,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
|
||||
// Registration itself runs under T5's slot declaration; here we pin the
|
||||
// frozen registration surface so the wiring layer can mount it verbatim.
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
323
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal file
323
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal 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, 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, 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: [], turnTimings: new Map(), 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()
|
||||
})
|
||||
})
|
||||
440
packages/client/ui-conversation/tests/search-card.spec.tsx
Normal file
440
packages/client/ui-conversation/tests/search-card.spec.tsx
Normal 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: [], turnTimings: new Map(), 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')
|
||||
})
|
||||
})
|
||||
@@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => {
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,12 @@ import { InputHub } from '../src/client/input/hub.ts'
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, cancel, loadOlder },
|
||||
session: { prompt, updateQueue, cancel, loadOlder },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
@@ -26,16 +27,18 @@ async function bench() {
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, root, scoped, prompt, cancel, loadOlder }
|
||||
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
|
||||
}
|
||||
|
||||
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()
|
||||
await b.runtime.dispose()
|
||||
@@ -44,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()
|
||||
@@ -62,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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,11 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import type { ClientContext } 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 { createChatStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
@@ -28,8 +31,24 @@ function fakeWiring() {
|
||||
return { wiring: shell, sink, shell }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
@@ -43,16 +62,16 @@ function workspace(id = 'w1'): WorkspaceView {
|
||||
}
|
||||
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, state: 'idle', phase: 'ready', error: null,
|
||||
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
@@ -61,16 +80,30 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
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, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: 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)
|
||||
@@ -111,6 +144,8 @@ function mount(
|
||||
}}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
t={t}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,10 +164,14 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
resolveSubmitMode={() => 'queue'}
|
||||
toggleCommandMenu={vi.fn()}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
t={t}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
@@ -140,7 +179,18 @@ function mount(
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
options.overlayTakeover === true
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
{opts?.fallback ?? null}
|
||||
</div>
|
||||
<div data-testid="composer-takeover">TAKEOVER</div>
|
||||
</>
|
||||
)
|
||||
: (opts?.fallback ?? null)
|
||||
)) as ConversationRootProps['renderSlotChain']
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
@@ -153,10 +203,11 @@ function mount(
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
selectWorkspace: retargetWorkspace,
|
||||
t,
|
||||
}
|
||||
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} />) },
|
||||
}
|
||||
@@ -171,10 +222,42 @@ 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'))
|
||||
})
|
||||
|
||||
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
const textarea = b.view.container.querySelector('textarea')
|
||||
expect(host).not.toBeNull()
|
||||
expect(seat).not.toBeNull()
|
||||
expect(header).not.toBeNull()
|
||||
// Header is column chrome above the scrollport; the seat sticks inside it.
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
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"]')
|
||||
expect(seat?.contains(takeover)).toBe(true)
|
||||
expect(seat?.contains(fallback)).toBe(true)
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
@@ -183,18 +266,24 @@ describe('ConversationRoot resident composer', () => {
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect(host?.contains(box)).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
// workspace's blank session (draft carry is apply-layer wiring).
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onPick(wid('second')) })
|
||||
@@ -202,17 +291,54 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
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')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -233,7 +359,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
],
|
||||
selectWorkspace,
|
||||
)
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
|
||||
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
@@ -243,7 +369,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
const chip = b.view.getByRole('button', { name: '选择工作区' })
|
||||
expect((chip as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(b.slotCalls).toContain('conversation.hero.workspace')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -12,12 +12,20 @@ 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, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
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 { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -87,6 +95,19 @@ describe('terminalCardModel', () => {
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
|
||||
// isError stays false on a failing command (the exit status is result
|
||||
// data), so this predicate is the row's only failure signal.
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
|
||||
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
@@ -217,40 +238,43 @@ describe('terminalCardModel', () => {
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('the expanded body is the command output inside the row scroll container', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
toggleRow(view)
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
expect(view.getByText('line-19')).toBeTruthy()
|
||||
expect(view.queryByText(/其余/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
|
||||
// Still one dot for the call, on the first row.
|
||||
@@ -275,14 +299,14 @@ describe('chat row terminal body', () => {
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
// The card states its own run state: a running command reads as running
|
||||
@@ -294,7 +318,7 @@ describe('chat row terminal body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -303,32 +327,45 @@ describe('chat row terminal body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
it('collapses to the summary row; the whole row toggles the command output', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
// Collapse back in place: the summary row returns, the card unmounts.
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
@@ -337,13 +374,22 @@ describe('BashRow terminal card', () => {
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<BashRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
|
||||
fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
|
||||
expect(runStateOf(runningView.container)).toBe('ongoing')
|
||||
cleanup()
|
||||
const settledView = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
|
||||
fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
|
||||
expect(runStateOf(settledView.container)).toBe('done')
|
||||
})
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
@@ -368,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -377,15 +445,17 @@ 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 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
@@ -400,16 +470,17 @@ describe('DetailsPanel Output section', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +578,7 @@ describe('DetailsPanel Output section', () => {
|
||||
// No terminal card: the generic path renders the result text in the Output
|
||||
// section's <pre> (the Input section has its own, hence the scoping).
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
const output = view.getByText('输出').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
@@ -524,8 +595,8 @@ describe('DetailsPanel Output section', () => {
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
expect(view.queryByText('输入')).toBeNull()
|
||||
expect(view.getByText('输出')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
@@ -560,9 +631,12 @@ 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: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
@@ -571,6 +645,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
@@ -586,7 +661,7 @@ describe('DetailsPanel Output section', () => {
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
|
||||
@@ -6,20 +6,27 @@
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, error badge, keyboard
|
||||
* activation).
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } 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'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -70,15 +77,31 @@ describe('planSummary', () => {
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} />)
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
it('starts collapsed with the per-status count summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('任务')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the completed segment while nothing is done yet', () => {
|
||||
render(<TodoPanel todos={[
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]} t={t} />)
|
||||
expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText(/已完成/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
@@ -87,34 +110,37 @@ describe('TodoPanel', () => {
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks every parallel active item, and counts them all in the header', () => {
|
||||
render(<TodoPanel todos={PARALLEL} />)
|
||||
render(<TodoPanel todos={PARALLEL} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
// The cap this branch removes made this list unreachable: three items carry
|
||||
// the in-progress glyph at once, and the header counts all three.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
|
||||
expect(screen.getByText('跑后台构建')).toBeTruthy()
|
||||
expect(screen.getByText('读源码')).toBeTruthy()
|
||||
expect(screen.getByText('1/5 tasks · 3 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成')).toBeTruthy()
|
||||
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,7 +148,7 @@ describe('TodoPanel', () => {
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
return { useProjection } as unknown as TodoDockProps
|
||||
return { useProjection, t } as unknown as TodoDockProps
|
||||
}
|
||||
|
||||
describe('TodoDock', () => {
|
||||
@@ -132,18 +158,18 @@ describe('TodoDock', () => {
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
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: -1 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,13 +179,14 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
function rowProps(block: unknown): TodoRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
t,
|
||||
} as unknown as TodoRowProps
|
||||
}
|
||||
|
||||
describe('TodoRow', () => {
|
||||
@@ -195,8 +222,8 @@ describe('TodoRow', () => {
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
|
||||
// A running call (no result yet) shows the ongoing dot, never the ok badge.
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
@@ -205,20 +232,26 @@ describe('TodoRow', () => {
|
||||
// A cancelled call wrote no todo/write: the row must not read as a completed update.
|
||||
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stopped.getByText('已中断')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the generic summary on malformed args and flags errors', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(screen.getByText('failed')).toBeTruthy()
|
||||
it('falls back to the generic summary on malformed args and marks the error state', () => {
|
||||
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
// Generic others summary: "<tool> · <raw>".
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
|
||||
it('falls back when parsed args carry no todos array', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
// The expanded body is the pretty-printed args, not the tool output.
|
||||
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -241,6 +274,6 @@ describe('TodoRow', () => {
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
291
packages/client/ui-conversation/tests/web-card.spec.tsx
Normal file
291
packages/client/ui-conversation/tests/web-card.spec.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment jsdom
|
||||
// The web render intent on the web side: the pure webCardModel derivation over
|
||||
// resultView, and the conversation render sites that consume it — the keyed
|
||||
// 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 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, 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 } 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'
|
||||
import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
const SEARCH_ARGS = '{"query":"deepseek harness"}'
|
||||
const FETCH_ARGS = '{"url":"https://example.com/page"}'
|
||||
|
||||
/** A web_search result view; overrides tune the sources / answer / truncation. */
|
||||
const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'search', truncated: false,
|
||||
answer: 'A short answer.',
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://plain.example.org/b' },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
/** A web_fetch result view. */
|
||||
const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
|
||||
})
|
||||
|
||||
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
|
||||
})
|
||||
|
||||
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'search text' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
|
||||
})
|
||||
|
||||
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
|
||||
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'fetch body' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
|
||||
})
|
||||
|
||||
describe('webCardModel', () => {
|
||||
it('derives a search card from the result view, projecting every source field', () => {
|
||||
expect(webCardModel(settledSearch())).toEqual({
|
||||
kind: 'search',
|
||||
answer: 'A short answer.',
|
||||
truncated: false,
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the search truncation flag and an absent answer', () => {
|
||||
const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
|
||||
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
|
||||
})
|
||||
|
||||
it('derives a fetch card from the result view', () => {
|
||||
expect(webCardModel(settledFetch())).toEqual({
|
||||
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
|
||||
})
|
||||
expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
|
||||
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('returns null for a running call, since the web card is result-only', () => {
|
||||
expect(webCardModel(runningSearch())).toBeNull()
|
||||
// Even a running call that somehow carried a web call view stays generic:
|
||||
// the derivation reads resultView only.
|
||||
expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a settled call whose result view is not a web card', () => {
|
||||
expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ 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', kind: 'search' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
|
||||
// A web card whose kind this UI version does not know (a newer host's
|
||||
// value) also takes the generic path, not a malformed fetch.
|
||||
const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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 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])
|
||||
|
||||
/** 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')} />)
|
||||
// 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 expands to the fetch card, titled Fetch', () => {
|
||||
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
|
||||
expect(view.getByText('Fetch')).toBeTruthy()
|
||||
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, 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()
|
||||
})
|
||||
|
||||
it('a failed web call keeps the summary row without the card', () => {
|
||||
const view = render(<WebRow {...rowProps(settledSearch({
|
||||
isError: true, resultView: { card: 'generic' },
|
||||
}), 'web_search')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
// The row reflects the error state so the summary line still reads as failed.
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
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 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()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
|
||||
}), 'echo')} t={t} />)
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel web Output section', () => {
|
||||
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: [], turnTimings: new Map(), 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,
|
||||
}
|
||||
}
|
||||
|
||||
it('renders the search card at full source allowance', () => {
|
||||
const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
// The Input JSON section survives beside it.
|
||||
expect(view.getByText(/"query"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the fetch card and keeps the fetched body below it', () => {
|
||||
const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
|
||||
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()
|
||||
// The card is a summary (URL + status only); the panel is the single-call
|
||||
// reading surface, so the fetched body still renders below the card.
|
||||
const output = view.getByText('输出').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
|
||||
})
|
||||
|
||||
it('a non-web result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settledSearch({ callView: null, resultView: null })],
|
||||
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
const output = view.getByText('输出').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('search text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('web toolview registration', () => {
|
||||
it('registers one WebRow under both web_search and web_fetch', () => {
|
||||
const registered: { key: string; 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 })
|
||||
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)
|
||||
// The load-order seam the render site depends on.
|
||||
expect(webToolview.inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user