Merge origin/master into web-permission-sandbox

39 conflicts resolved per the reattachment plan (missions worktree-projbiz
0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp,
ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and
client summary fields; the approval takeover, waitingApprovals tracking, and
PendingApproval domain face carry over onto the master structure. The two
new host specs follow the runtime->apiproxy rename. Dead PR-side wiring
(ConversationInjected permissions/setPermission spread, InputBar controls
prop, boot.ts sandbox composition) resolves to master and its replacement
lands in follow-up commits.
This commit is contained in:
imccyu
2026-07-28 21:20:00 +08:00
3427 changed files with 156166 additions and 59003 deletions

View File

@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))

View File

@@ -70,30 +70,31 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
expect(kinds).toEqual(['user', 'message', 'tool'])
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
input: 10, output: 20, think: 5, timeSeconds: 5,
})
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool?.timeSeconds).toBe(1.3)
})
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
nodes: [] as unknown as ConversationSnapshot['nodes'],
codeDispatches: new Map(),
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
}],
})
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
})
@@ -111,10 +112,10 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
})
it('builds a wall-span step description with a tool histogram', () => {
@@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
})
@@ -154,10 +155,10 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage on the fallback Message row when assistant has no text block', () => {
@@ -168,8 +169,8 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
})
@@ -196,11 +197,59 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
.flatMap(g => g.cells)
.find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
})
describe('run_code sub-dispatch cells', () => {
const runCodeNodes = [
{
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
blocks: [
{ kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
],
},
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const settledSub = (n: number, name: string, start: number, end: number) => ({
kind: 'tool-result' as const, seq: 100 + n, time: end,
callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
})
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [
settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
})
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null,
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
})

View File

@@ -15,23 +15,19 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
/** Fallback-only chain stub (no composer takeover in these benches). */
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
@@ -54,20 +50,25 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
/** Standalone view props: the session-scope standard kit the outlet would bake. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
@@ -75,6 +76,8 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never,
} as unknown as ConvViewProps
}
@@ -104,11 +107,11 @@ function tabsOf(slots: SlotsService): ViewTab[] {
.map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
}
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()
@@ -121,28 +124,30 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const View = entry.component as FC<ConvViewProps>
return (
<View
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
)
}) as unknown as ConversationRootProps['renderSlot']
}) as unknown as ConversationSessionProps['renderSlot']
return render(
<ConversationRoot
<ConversationSession
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
useWorkspaces={emptyWorkspaces()}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{
list: () => tabsOf(slots),
subscribe: (fn) => slots.subscribe('conversation.view', fn),
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}}
send={vi.fn()}
stop={vi.fn()}
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
@@ -163,7 +168,7 @@ describe('plugin registration', () => {
it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
})
})
@@ -172,10 +177,9 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench()
mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
expect(screen.queryByText(/turns ·/)).toBeNull()
expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy()
@@ -198,7 +202,7 @@ describe('tab switching in ConversationRoot', () => {
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
const b = await bench()
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
mount(b.slots, [])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -221,12 +225,12 @@ describe('span derivation', () => {
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
@@ -234,7 +238,7 @@ describe('span derivation', () => {
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
@@ -248,6 +252,120 @@ describe('WaterfallView standalone branches', () => {
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
})
describe('deriveSubSpans (waterfall lanes)', () => {
const dispatchNodes = [
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
it('scales settled lanes into the dispatch window with real durations', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const turn3 = lanes.get(3)
expect(turn3).toHaveLength(2)
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
})
it('a running lane extends to the window end with a null duration', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
})
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
})
it('waterfall renders sub-span lanes under the owning turn row', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never,
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const lane = view.container.querySelector('[data-subspan]')
expect(lane).not.toBeNull()
expect(lane!.textContent).toContain('bash')
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
})
it('waterfall labels a settle-only lane as duration unknown', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never,
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const bar = view.container.querySelector('[data-timing="unknown"]')
expect(bar).not.toBeNull()
expect(bar!.getAttribute('title')).toContain('duration unknown')
})
})