feat(web): Code Mode sub-calls in the trajectory and waterfall views
Trajectory: the layout fold interleaves one subtool cell per sub-dispatch after its parent Tool cell (assistant-block calls, orphan results, and running calls alike), indexes sequential across the interleave; settled durations come from the start/settle pair, running sub-calls show the em dash. New Sub tag (business tint) + 28px indent. Waterfall: deriveSubSpans folds the dispatch index into per-turn lanes with REAL wall time — each parent's window is first start → last settle and every lane's offset/width is its fraction of it, so parallel sub-calls visibly overlap; running lanes extend to the window end at reduced opacity. Lanes draw under the owning turn row. Both views read codeDispatches through the standard snapshot hook; no new wire data, replay renders identically to live. Specs pin interleave order, durations, the running arms, window fractions, and the rendered lane.
This commit is contained in:
@@ -70,7 +70,7 @@ 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))
|
||||
@@ -86,6 +86,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
@@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
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()
|
||||
@@ -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,7 +155,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
blocks: [{ kind: 'text', text: 'ok2' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
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'])
|
||||
@@ -168,7 +169,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
|
||||
},
|
||||
] 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')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
@@ -196,7 +197,7 @@ 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')
|
||||
@@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user