Browse Source

merge compact duration display

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
#	apps/web/tests/snapshots/subagent-conversation/tree.expected.md
#	packages/client/ui-subagent/README.i18n.yaml
#	packages/client/ui-subagent/README.md
#	packages/client/ui-subagent/README.zh.md
#	packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
#	packages/client/ui-subagent/tests/conversation-ui.spec.tsx
kingwl 1 month ago
parent
commit
583d532bc3

+ 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: e0869cf4128b1cf0fdecb39dd7a23aa81b92c380
-2026-07-27-web-subagent-conversations.zh.md: ceeb929ef73fe099fbd6dcbd8378745708ad2097
+2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6
+2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05

+ 4 - 4
.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, 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 exact active-turn duration come from the list's retained projection values. `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. 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, formats whole seconds, and 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. Neither metric implies 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,8 +104,8 @@ 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, token totals, second-precision running and frozen inactive durations, 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, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and 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

+ 4 - 4
.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、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与精确的活跃轮次耗时来自列表保留的投影值。`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` 刷新来更新。消息投递时仍以提示词响应为权威依据。
 
-健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,它会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟。对 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,8 +104,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
-- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
-- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行以及聚合 `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。
 
 ## 后果

+ 8 - 1
apps/web/tests/scaffold.ts

@@ -511,7 +511,14 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
     .split(workspaceCwd).join('{{cwd}}')
     .split(base).join('{{workspace}}')
     .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
-    .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
+    .replace(
+      /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+s|\d+(?:\.\d+)?ms)\b/g,
+      duration => duration.startsWith('~') ? duration : '{{duration}}',
+    )
+    .replace(
+      /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|秒)/g,
+      duration => duration.startsWith('约') ? duration : '{{duration}}',
+    )
     // Message IconActions clocks widen by calendar day/year; collapse every
     // shape so goldens stay stable across midnight and year boundaries.
     .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')

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

@@ -1,8 +1,8 @@
 - tree "Subagent sessions":
+  - 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 7.9K tok {{duration}}
   - group:
     - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}
-  - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok {{duration}}

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

@@ -108,7 +108,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     childId = started.childId
     await waitForAgentToSettle(scaffold, childId)
     oneShotId = sessionId('recorded-one-shot')
-    const oneShotAt = Date.now()
+    const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
+    const oneShotAt = Date.now() - oneShotDurationMs
     await scaffold.ctx.sessionPersistence.create({
       version: SESSION_FORMAT_VERSION,
       id: oneShotId,
@@ -146,7 +147,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       {
         type: 'turn/end',
         seq: 3,
-        time: oneShotAt + 3,
+        time: oneShotAt + oneShotDurationMs,
         data: { turn: 1, reason: { kind: 'completed' } },
       },
     ] as SessionEvent[])
@@ -199,14 +200,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
     expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
     await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
-      {
-        kind: 'child', id: childId, mode: 'continuable', label: LABEL,
-        activity: 'inactive', hasChildren: true,
-      },
       {
         kind: 'child', id: oneShotId, mode: 'one-shot',
         label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
       },
+      {
+        kind: 'child', id: childId, mode: 'continuable', label: LABEL,
+        activity: 'inactive', hasChildren: true,
+      },
     ])
     await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
       {
@@ -301,6 +302,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     expect(await page.getByRole('button', {
       name: `Expand ${ONE_SHOT_LABEL} descendants`,
     }).count()).toBe(0)
+    const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
+    expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
+    expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
     await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
     const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
     const childLabel = await childRow.getAttribute('aria-label')

+ 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: e13532050cf1a0cbd01ef845eafdc41787c19807
-README.zh.md: f5bf05978d02676458576dd25f05f3d88419c9a5
+README.md: 1ced0a82e5ec9340ee87620dfaaf7655fe4f6a3e
+README.zh.md: 778d3354ac78658044c2a13b3af632510b547f04

File diff suppressed because it is too large
+ 0 - 1
packages/client/ui-subagent/README.md


File diff suppressed because it is too large
+ 0 - 1
packages/client/ui-subagent/README.zh.md


+ 71 - 10
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx

@@ -97,16 +97,54 @@ function activityDuration(
   return timing.settledMs + Math.max(0, end - timing.active.since)
 }
 
-/** Format a non-negative duration to seconds without dropping larger units. */
-function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
+interface DurationParts {
+  seconds: number
+  minutes: number
+  hours: number
+  days: number
+  totalMinutes: number
+  totalHours: number
+}
+
+function splitDuration(ms: number): DurationParts {
   const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
-  const seconds = totalSeconds % 60
   const totalMinutes = Math.floor(totalSeconds / 60)
-  const minutes = totalMinutes % 60
-  const hours = Math.floor(totalMinutes / 60)
-  if (hours > 0) {
+  const totalHours = Math.floor(totalMinutes / 60)
+  return {
+    seconds: totalSeconds % 60,
+    minutes: totalMinutes % 60,
+    hours: totalHours % 24,
+    days: Math.floor(totalHours / 24),
+    totalMinutes,
+    totalHours,
+  }
+}
+
+/** Format a duration with decreasing visual precision at larger scales. */
+function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
+  const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms)
+  if (days >= 365) {
+    const years = Math.floor(days / 365)
+    const months = Math.floor((days % 365) / 30)
+    return months === 0
+      ? t('duration.years', { years })
+      : t('duration.yearsMonths', { years, months })
+  }
+  if (days >= 30) {
+    const months = Math.floor(days / 30)
+    const remainingDays = days % 30
+    return remainingDays === 0
+      ? t('duration.months', { months })
+      : t('duration.monthsDays', { months, days: remainingDays })
+  }
+  if (days > 0) {
+    return hours === 0
+      ? t('duration.days', { days })
+      : t('duration.daysHours', { days, hours })
+  }
+  if (totalHours > 0) {
     return t('duration.hours', {
-      hours,
+      hours: totalHours,
       minutes: String(minutes).padStart(2, '0'),
       seconds: String(seconds).padStart(2, '0'),
     })
@@ -120,6 +158,19 @@ function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
   return t('duration.seconds', { seconds })
 }
 
+/** Preserve exact whole seconds for hover and accessible naming. */
+function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
+  const { seconds, minutes, hours, days } = splitDuration(ms)
+  return days === 0
+    ? formatDuration(ms, t)
+    : t('duration.exactDays', {
+      days,
+      hours: String(hours).padStart(2, '0'),
+      minutes: String(minutes).padStart(2, '0'),
+      seconds: String(seconds).padStart(2, '0'),
+    })
+}
+
 /** Aggregate the complete subagent-only descendant subtree from flat summaries. */
 function summarizeDescendants(
   sessionId: SessionId,
@@ -256,8 +307,11 @@ function CatalogRows({
           : `${formatTokens(totalTokens)} tok`
         const durationMetric = durationMs === undefined
           ? undefined
-          : formatDuration(durationMs, t)
-        const metrics = [tokenMetric, durationMetric]
+          : {
+            compact: formatDuration(durationMs, t),
+            exact: formatExactDuration(durationMs, t),
+          }
+        const metrics = [tokenMetric, durationMetric?.exact]
           .filter(value => value !== undefined)
           .join(' · ')
 
@@ -319,7 +373,14 @@ function CatalogRows({
                 {metrics !== '' && (
                   <span className={css.metrics}>
                     {tokenMetric !== undefined && <span className={css.metricToken}>{tokenMetric}</span>}
-                    {durationMetric !== undefined && <span className={css.metricDuration}>{durationMetric}</span>}
+                    {durationMetric !== undefined && (
+                      <span
+                        className={css.metricDuration}
+                        title={t('duration.exactTitle', { duration: durationMetric.exact })}
+                      >
+                        {durationMetric.compact}
+                      </span>
+                    )}
                   </span>
                 )}
               </div>

+ 16 - 0
packages/client/ui-subagent/src/client/locales.ts

@@ -11,6 +11,14 @@ export const zh = {
   'duration.seconds': '{seconds}秒',
   'duration.minutes': '{minutes}分{seconds}秒',
   'duration.hours': '{hours}小时{minutes}分{seconds}秒',
+  'duration.days': '{days}天',
+  'duration.daysHours': '{days}天{hours}小时',
+  'duration.months': '约{months}个月',
+  'duration.monthsDays': '约{months}个月{days}天',
+  'duration.years': '约{years}年',
+  'duration.yearsMonths': '约{years}年{months}个月',
+  'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒',
+  'duration.exactTitle': '总活跃耗时:{duration}',
   'loading.label': '正在加载子代理…',
   'loading.aria': '正在加载子代理',
   'load.error': '无法加载子代理',
@@ -40,6 +48,14 @@ export const en: Record<SubagentKey, string> = {
   'duration.seconds': '{seconds}s',
   'duration.minutes': '{minutes}m {seconds}s',
   'duration.hours': '{hours}h {minutes}m {seconds}s',
+  'duration.days': '{days}d',
+  'duration.daysHours': '{days}d {hours}h',
+  'duration.months': '~{months}mo',
+  'duration.monthsDays': '~{months}mo {days}d',
+  'duration.years': '~{years}y',
+  'duration.yearsMonths': '~{years}y {months}mo',
+  'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s',
+  'duration.exactTitle': 'Total active duration: {duration}',
   'loading.label': 'Loading subagents…',
   'loading.aria': 'Loading subagents',
   'load.error': 'Unable to load subagents',

+ 21 - 2
packages/client/ui-subagent/tests/conversation-ui.spec.tsx

@@ -242,12 +242,21 @@ describe('SubagentCatalogAction', () => {
 
   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
+    const day = 24 * hour
     vi.useFakeTimers()
     vi.setSystemTime(now)
     const rows = [
       ['running', 'running', 65_000, now - 5_000, now - 1_000, now],
       ['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000],
       ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000],
+      ['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now],
+      ['whole-day', 'inactive', day, undefined, undefined, now],
+      ['months', 'inactive', 192 * day, undefined, undefined, now],
+      ['whole-month', 'inactive', 30 * day, undefined, undefined, now],
+      ['years', 'inactive', 832 * day, undefined, undefined, now],
+      ['whole-year', 'inactive', 365 * day, undefined, undefined, now],
     ] as const
     const usageById = {
       running: {
@@ -293,13 +302,15 @@ describe('SubagentCatalogAction', () => {
               ? {}
               : { active: { since: activeSince, through: activeThrough } }),
           },
-          tokenUsage: usageById[id],
+          tokenUsage: id in usageById
+            ? usageById[id as keyof typeof usageById]
+            : undefined,
         },
       }]
     })) as Record<SessionId, SessionSummary>
     const input = props(catalog({ entries }), {}, summaries)
     render(<SubagentCatalogAction {...input} />)
-    fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ }))
+    fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
 
     const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
     const runningMetrics = within(runningRow)
@@ -309,6 +320,14 @@ describe('SubagentCatalogAction', () => {
     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秒')
+    expect(screen.getByText('1天')).toBeTruthy()
+    expect(screen.getByText('约6个月12天')).toBeTruthy()
+    expect(screen.getByText('约1个月')).toBeTruthy()
+    expect(screen.getByText('约2年3个月')).toBeTruthy()
+    expect(screen.getByText('约1年')).toBeTruthy()
 
     await vi.advanceTimersByTimeAsync(1_000)
     expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()

Some files were not shown because too many files changed in this diff