Jelajahi Sumber

Merge remote-tracking branch 'origin/master' into pr/tui-details-fold

Turtle 1 bulan lalu
induk
melakukan
4fa1d48bf9

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: a8c7c4716fe797f94193b9ee7560394b0e0ff54a
-2026-07-27-web-subagent-conversations.zh.md: 09980b30acf84379ae99e8d30122fcd69f57af75
+2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6
+2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
 | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. |
 | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. |
 | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. |
-| Rows show labels, state, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
+| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
 
 ## Product contract
 
@@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a
 
 `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
 
-Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Below one day the menu formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. The menu advances its local clock only while a known descendant is running; an inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. The duration does not imply a durable outcome.
+Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome.
 
 Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
 
@@ -104,13 +104,13 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
 - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
 - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
 - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
-- jsdom tests pin the aggregate descendant count and activity, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
-- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
+- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
+- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
 - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
 
 ## Consequences
 
-- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while duration reuses projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight.
+- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight.
 - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
 - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path.
 - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut.

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
 | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
 | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
-| 条目显示 label、状态与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
+| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
 
 ## 产品契约
 
@@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 
 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
 
-健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。菜单仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。
+健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。
 
 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
 
@@ -104,13 +104,13 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
-- jsdom 测试固定后代聚合计数与活动状态、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
-- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
+- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
+- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
 
 ## 后果
 
-- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
+- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。

+ 2 - 0
apps/web/tests/snapshots/subagent-conversation/branchless.expected.md

@@ -0,0 +1,2 @@
+- tree "Subagent sessions":
+  - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}}

+ 4 - 4
apps/web/tests/snapshots/subagent-conversation/tree.expected.md

@@ -1,8 +1,8 @@
 - tree "Subagent sessions":
-  - treeitem "event-sourcing reviewer one-shot · not running {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running ~6mo 12d
-  - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}}" [expanded] [level=1]:
+  - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
+  - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
     - button "Collapse event-sourcing researcher descendants":
       - img
-    - text: event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}}
+    - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}}
   - group:
-    - treeitem "example editor continuable · not running {{duration}}" [level=2]
+    - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}

+ 10 - 1
apps/web/tests/subagent-conversation.e2e.ts

@@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
 const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
+const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url))
 const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
 const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
 const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
@@ -396,7 +397,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
   it('opens an unavailable persisted grandchild after recording the available child', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
     await page.getByRole('button', { name: '1 subagent' }).click()
-    await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
+    const tree = page.getByRole('tree', { name: 'Subagent sessions' })
+    const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
+    expect(await nestedRow.locator(':scope > *').count()).toBe(1)
+    await compareOrRefreshGolden(
+      BRANCHLESS_EXPECTED,
+      await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
+      MODE,
+    )
+    await nestedRow.click()
     await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
     const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
     const crumbs = await hierarchy.getByRole('button').allTextContents()

+ 2 - 1
docs/module-graph.md

@@ -983,6 +983,7 @@ flowchart TD
   pkg_client_ui_subagent --> pkg_client_ui_slots
   pkg_client_ui_subagent --> pkg_invariants
   pkg_client_ui_subagent --> pkg_subagent
+  pkg_client_ui_subagent --> pkg_token_meter
   pkg_agent_spine_demo --> pkg_agent
   pkg_agent_spine_demo --> pkg_agent_loop
   pkg_agent_spine_demo --> pkg_goal
@@ -1240,7 +1241,7 @@ flowchart TD
 | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
-| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent) |
+| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
 | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
packages/client/ui-subagent/README.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 packages/client/ui-subagent/README.md
-README.md: c2a005a9c9716d246bb4299ab1654314da21643a
-README.zh.md: 4fb42b39fd7810f5d60e1b8a00bd9ad7d8752feb
+README.md: cb210b219a8c66985eb4e1370468372eed9614b4
+README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c

File diff ditekan karena terlalu besar
+ 0 - 1
packages/client/ui-subagent/README.md


File diff ditekan karena terlalu besar
+ 0 - 1
packages/client/ui-subagent/README.zh.md


+ 2 - 0
packages/client/ui-subagent/package.json

@@ -49,6 +49,7 @@
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-token-meter": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "devDependencies": {
@@ -61,6 +62,7 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@types/react": "~18.3.1",
     "cordis": "^4.0.0-rc.7"
   },

+ 14 - 3
packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css

@@ -173,19 +173,30 @@
 }
 
 .summary,
-.time {
+.metrics {
   color: var(--dsw-alias-label-tertiary);
   font-size: 11px;
   line-height: 16px;
 }
 
-.time {
+.metrics {
+  display: grid;
+  grid-template-rows: 18px 16px;
   flex: none;
-  margin-top: 16px;
   font-variant-numeric: tabular-nums;
+  text-align: right;
   white-space: nowrap;
 }
 
+.metricToken {
+  grid-row: 1;
+  line-height: 18px;
+}
+
+.metricDuration {
+  grid-row: 2;
+}
+
 .children {
   position: relative;
   margin-left: 18px;

+ 46 - 12
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx

@@ -12,6 +12,7 @@ import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-cl
 import { NS } from './locales.ts'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-subagent/client'
+import type {} from '@deepseek-ai/dsh-token-meter/client'
 import css from './SubagentCatalogAction.module.css'
 
 type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
@@ -59,6 +60,26 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
     : Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
 }
 
+/** Compact token count shared in shape with the conversation stats strip. */
+function formatTokens(value: number): string {
+  const scaled = (next: number): string => next >= 100
+    ? String(Math.round(next))
+    : String(Math.round(next * 10) / 10)
+  if (value < 1_000) return String(value)
+  if (value < 1_000_000) return `${scaled(value / 1_000)}K`
+  return `${scaled(value / 1_000_000)}M`
+}
+
+/** Sum the four disjoint durable provider-usage buckets. */
+function tokenTotal(
+  usage: SessionProjectionMap['tokenUsage'] | undefined,
+): number | undefined {
+  return usage === undefined
+    ? undefined
+    : usage.uncachedInputTokens + usage.outputTokens
+      + usage.cacheReadTokens + usage.cacheWriteTokens
+}
+
 /** Exact whole-second active-turn duration for one catalog row. */
 function activityDuration(
   summary: SessionSummary | undefined,
@@ -216,6 +237,9 @@ function CatalogRows({
   openChild, refresh, toggleBranch, closeCatalog, t,
 }: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
   const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
+  const reserveDisclosure = catalog.entries.some(
+    entry => entry.kind === 'child' && entry.hasChildren,
+  )
   return (
     <>
       {emptyLoading && (
@@ -252,7 +276,7 @@ function CatalogRows({
                 className={`${css.row} ${css.disabled}`}
                 title={reason}
               >
-                <span className={css.disclosureSpace} />
+                {reserveDisclosure && <span className={css.disclosureSpace} />}
                 <StateDot state="error" />
                 <span className={css.content}>
                   <span className={css.label}>{entry.id}</span>
@@ -275,17 +299,24 @@ function CatalogRows({
         const secondary = [summary?.title, mode, activity]
           .filter(value => value !== undefined)
           .join(' · ')
+        const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage)
         const durationMs = activityDuration(
           summary,
           entry.activity,
           now,
         )
-        const duration = durationMs === undefined
+        const tokenMetric = totalTokens === undefined
+          ? undefined
+          : `${formatTokens(totalTokens)} tok`
+        const durationMetric = durationMs === undefined
           ? undefined
           : {
             compact: formatDuration(durationMs, t),
             exact: formatExactDuration(durationMs, t),
           }
+        const metrics = [tokenMetric, durationMetric?.exact]
+          .filter(value => value !== undefined)
+          .join(' · ')
 
         const open = (): void => {
           openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
@@ -317,16 +348,14 @@ function CatalogRows({
               role="treeitem"
               tabIndex={0}
               aria-level={level}
-              aria-label={[label, secondary, duration?.exact]
-                .filter(value => value !== undefined)
-                .join(' ')}
+              aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')}
               {...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
               className={css.row}
               onClick={open}
               onKeyDown={handleKey}
             >
               {knownLeaf
-                ? <span className={css.disclosureSpace} />
+                ? reserveDisclosure && <span className={css.disclosureSpace} />
                 : (
                   <button
                     type="button"
@@ -344,12 +373,17 @@ function CatalogRows({
                   <span className={css.label}>{label}</span>
                   <span className={css.summary}>{secondary}</span>
                 </span>
-                {duration !== undefined && (
-                  <span
-                    className={css.time}
-                    title={t('duration.exactTitle', { duration: duration.exact })}
-                  >
-                    {duration.compact}
+                {metrics !== '' && (
+                  <span className={css.metrics}>
+                    {tokenMetric !== undefined && <span className={css.metricToken}>{tokenMetric}</span>}
+                    {durationMetric !== undefined && (
+                      <span
+                        className={css.metricDuration}
+                        title={t('duration.exactTitle', { duration: durationMetric.exact })}
+                      >
+                        {durationMetric.compact}
+                      </span>
+                    )}
                   </span>
                 )}
               </div>

+ 50 - 8
packages/client/ui-subagent/tests/conversation-ui.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import type {
   SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
@@ -151,6 +151,7 @@ describe('SubagentCatalogAction', () => {
     expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
     expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
     expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
+    expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2)
 
     fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
     expect(input.openChild).toHaveBeenCalledWith({
@@ -177,6 +178,19 @@ describe('SubagentCatalogAction', () => {
     expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
   })
 
+  it('removes the disclosure column from branchless catalog levels', () => {
+    const input = props(catalog({
+      entries: [{
+        kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
+        activity: 'running', hasChildren: false,
+      }],
+    }))
+    render(<SubagentCatalogAction {...input} />)
+    fireEvent.click(screen.getByRole('button', { name: /1 个子代理/ }))
+
+    expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
+  })
+
   it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
     const input = props(catalog())
     render(<SubagentCatalogAction {...input} />)
@@ -240,7 +254,7 @@ describe('SubagentCatalogAction', () => {
     })
   })
 
-  it('ticks active duration by seconds and freezes inactive rows', async () => {
+  it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => {
     const now = 2_000_000_000_000
     const minute = 60_000
     const hour = 60 * minute
@@ -258,6 +272,26 @@ describe('SubagentCatalogAction', () => {
       ['years', 'inactive', 832 * day, undefined, undefined, now],
       ['whole-year', 'inactive', 365 * day, undefined, undefined, now],
     ] as const
+    const usageById = {
+      running: {
+        uncachedInputTokens: 1_000,
+        outputTokens: 200,
+        cacheReadTokens: 3_000,
+        cacheWriteTokens: 400,
+      },
+      finished: {
+        uncachedInputTokens: 123,
+        outputTokens: 0,
+        cacheReadTokens: 0,
+        cacheWriteTokens: 0,
+      },
+      interrupted: {
+        uncachedInputTokens: 123_000_000,
+        outputTokens: 0,
+        cacheReadTokens: 0,
+        cacheWriteTokens: 0,
+      },
+    } as const
     const entries = rows.map(([id, activity]) => ({
       kind: 'child' as const,
       id: id as SessionId,
@@ -282,6 +316,9 @@ describe('SubagentCatalogAction', () => {
               ? {}
               : { active: { since: activeSince, through: activeThrough } }),
           },
+          tokenUsage: id in usageById
+            ? usageById[id as keyof typeof usageById]
+            : undefined,
         },
       }]
     })) as Record<SessionId, SessionSummary>
@@ -289,9 +326,14 @@ describe('SubagentCatalogAction', () => {
     render(<SubagentCatalogAction {...input} />)
     fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
 
-    expect(screen.getByRole('treeitem', { name: /running.*1分10秒/ })).toBeTruthy()
-    expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy()
-    expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy()
+    const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
+    const runningMetrics = within(runningRow)
+    const tokenMetric = runningMetrics.getByText('4.6K tok')
+    const durationMetric = runningMetrics.getByText('1分10秒')
+    expect(tokenMetric.parentElement).toBe(durationMetric.parentElement)
+    expect(tokenMetric.nextElementSibling).toBe(durationMetric)
+    expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
+    expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
     expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy()
     expect(screen.getByText('12天5小时').getAttribute('title'))
       .toBe('总活跃耗时:12天05小时06分07秒')
@@ -302,9 +344,9 @@ describe('SubagentCatalogAction', () => {
     expect(screen.getByText('约1年')).toBeTruthy()
 
     await vi.advanceTimersByTimeAsync(1_000)
-    expect(screen.getByRole('treeitem', { name: /running.*1分11秒/ })).toBeTruthy()
-    expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy()
-    expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy()
+    expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
+    expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
+    expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
   })
 
   it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {

+ 3 - 0
packages/client/ui-subagent/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../../llm/token-meter"
+    },
     {
       "path": "../../subagent/subagent"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -1906,6 +1906,9 @@ importers:
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../../subagent/subagent
+      '@deepseek-ai/dsh-token-meter':
+        specifier: workspace:^
+        version: link:../../llm/token-meter
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini