07akioni 2 luni în urmă
părinte
comite
06143b1a86

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d
-2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55
+2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413
+2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md

@@ -14,9 +14,9 @@ The trajectory tab needs a reusable step row and turn-list chrome that can show
 
 - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection.
 - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8.
-- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`).
+- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor.
 
-[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration).
+[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration).
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md

@@ -14,9 +14,9 @@ trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展
 
 - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。
 - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。
-- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。
+- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。
 
-[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。
+[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。
 
 ## Alternatives considered
 

+ 55 - 11
packages/client/ui-trajectory/src/client/layout.ts

@@ -54,6 +54,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
   const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
   let index = 0
   let prevAbsTime: number | null = null
+  let lastAssistantTurn: number | null = null
 
   const bucket = (turn: number) => {
     let entry = turns.get(turn)
@@ -74,9 +75,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     steps.set(step, list)
   }
 
-  for (const node of nodes) {
+  for (let i = 0; i < nodes.length; i++) {
+    const node = nodes[i]
+    /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
+    if (node === undefined) continue
     if (node.kind === 'user' || node.kind === 'steering') {
-      const turn = node.kind === 'steering' ? node.turn : 0
+      // user/message has no turn on the wire; enclose it in the next assistant
+      // (or partial) turn, else open the turn after the last assistant.
+      const turn = node.kind === 'steering'
+        ? node.turn
+        : enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
       pushMessage(turn, {
         absTime: finiteTime(node.time),
         cell: {
@@ -96,6 +104,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       const last = laidList[laidList.length - 1]
       if (last !== undefined) index = last.cell.index
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
+      lastAssistantTurn = node.turn
+      continue
+    }
+    if (node.kind === 'context') {
+      // No trajectory cell, but the surface still advances the duration cursor.
+      prevAbsTime = finiteTime(node.time) ?? prevAbsTime
       continue
     }
     if (node.kind === 'tool-result') {
@@ -149,6 +163,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     })
   }
 
+  // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
   const prologue = turns.get(0)
   if (prologue !== undefined) {
     turns.delete(0)
@@ -269,11 +284,9 @@ function expandAssistant(
         index: ++index, kind: 'message', text: summarizeText(block.text),
         timeSeconds: messageDuration,
       }
-      if (!usageAttached && usage !== undefined) {
-        if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
-        if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
-        if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
-        usageAttached = true
+      if (!usageAttached) {
+        attachUsage(cell, usage)
+        usageAttached = usage !== undefined
       }
       out.push({ absTime: nodeAbs, cell })
       continue
@@ -302,14 +315,45 @@ function expandAssistant(
   }
 
   if (out.length === 0 && !streaming) {
-    out.push({
-      absTime: nodeAbs,
-      cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration },
-    })
+    // Reasoning-only / empty success still owns provider usage on the Message row.
+    const cell: TrajectoryCellProps = {
+      index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
+    }
+    attachUsage(cell, usage)
+    out.push({ absTime: nodeAbs, cell })
   }
   return out
 }
 
+/**
+ * Turn that encloses a user/message: next assistant/steering turn, else the
+ * in-flight partial, else the turn after the last finalized assistant (or 1).
+ */
+function enclosingUserTurn(
+  nodes: ConversationSnapshot['nodes'],
+  userIndex: number,
+  partial: ConversationSnapshot['partial'],
+  lastAssistantTurn: number | null,
+): number {
+  for (let i = userIndex + 1; i < nodes.length; i++) {
+    const n = nodes[i]
+    /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
+    if (n === undefined) continue
+    if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
+  }
+  if (partial !== null) return partial.turn
+  if (lastAssistantTurn !== null) return lastAssistantTurn + 1
+  return 1
+}
+
+/** Copy provider usage onto a Message cell when present. */
+function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
+  if (usage === undefined) return
+  if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
+  if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
+  if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
+}
+
 function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
   const map = new Map<string, ToolResultNode>()
   for (const node of nodes) {

+ 63 - 0
packages/client/ui-trajectory/tests/layout.spec.tsx

@@ -140,4 +140,67 @@ describe('deriveTrajectoryLayout', () => {
     const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
   })
+
+  it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
+    const nodes = [
+      { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
+      {
+        kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
+        blocks: [{ kind: 'text', text: 'ok1' }],
+      },
+      { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
+      {
+        kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
+        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'])
+  })
+
+  it('keeps usage on the fallback Message row when assistant has no text block', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
+        blocks: [{ kind: 'reasoning', text: '…' }],
+        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')
+    expect(message).toMatchObject({
+      text: '', input: 11, output: 22, think: 3,
+    })
+  })
+
+  it('advances the duration cursor over context nodes', () => {
+    const nodes = [
+      { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
+      {
+        kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
+        blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
+      },
+      {
+        kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
+        call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
+        content: [], isError: false, callView: null, resultView: null,
+      },
+      {
+        kind: 'context', seq: 4, time: 9_000,
+        content: [{ type: 'text', text: 'extra' }], source: null,
+      },
+      {
+        kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
+        blocks: [{ kind: 'text', text: 'done' }],
+      },
+    ] 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' && c.text === 'done')
+    // From context at 9s, not from the earlier user/tool surfaces.
+    expect(message?.timeSeconds).toBe(1)
+  })
 })