show subagent usage and active duration

This commit is contained in:
kingwl
2026-08-02 23:31:24 +08:00
parent baf133d134
commit b30686d634
35 changed files with 438 additions and 88 deletions

View File

@@ -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: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2
README.md: 2d507dde49f3796f3bbd00c239a768cdfd8a561d
README.zh.md: d0143858ff7eac1437fce381378ebcfad2187f2a

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, total durable provider usage, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.

View File

@@ -4,7 +4,7 @@
Web subagent 功能 owner`conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态、由日志支撑的可选 title、提供方的持久化总用量,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支键盘焦点与运行中耗时时钟。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome其 Session 会通过 `subagent.prompt` 路由child 运行期间,输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主 context也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript文本记录
## 已知限制与暂缓事项
- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份具备安全授权的取消按钮。
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

View File

@@ -46,6 +46,8 @@
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@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": {
@@ -55,6 +57,8 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@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"
},

View File

@@ -173,15 +173,17 @@
}
.summary,
.time {
.metrics {
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.time {
.metrics {
flex: none;
margin-top: 16px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.children {

View File

@@ -2,13 +2,16 @@ import {
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
} from 'react'
import type {
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress,
SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
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]
@@ -53,19 +56,53 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
}
/** Compact trailing activity time for a catalog row. */
function relativeTime(updatedAt: number | undefined, now: number): string | undefined {
if (updatedAt === undefined) return undefined
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
const diff = Math.max(0, now - updatedAt)
if (diff < minute) return '刚刚'
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
if (diff < day) return `${Math.floor(diff / hour)}小时`
if (diff < 30 * day) return `${Math.floor(diff / day)}`
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
return `${Math.floor(diff / (365 * day))}`
/** 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(
timing: SessionProjectionMap['subagentTiming'] | undefined,
activity: 'running' | 'inactive',
updatedAt: number | undefined,
now: number,
): number | undefined {
if (timing === undefined) return undefined
if (timing.activeSince === undefined) return timing.settledMs
const end = activity === 'running' ? now : updatedAt ?? timing.activeSince
return timing.settledMs + Math.max(0, end - timing.activeSince)
}
/** Format a non-negative duration to seconds without dropping larger units. */
function formatDuration(ms: number): string {
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) {
return `${hours}小时${String(minutes).padStart(2, '0')}${String(seconds).padStart(2, '0')}`
}
if (totalMinutes > 0) {
return `${totalMinutes}${String(seconds).padStart(2, '0')}`
}
return `${seconds}`
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
@@ -190,7 +227,17 @@ function CatalogRows({
const secondary = [summary?.title, mode, activity]
.filter(value => value !== undefined)
.join(' · ')
const time = relativeTime(summary?.updatedAt, now)
const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage)
const durationMs = activityDuration(
summary?.projectionValues?.subagentTiming,
entry.activity,
summary?.updatedAt,
now,
)
const metrics = [
totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`,
durationMs === undefined ? undefined : formatDuration(durationMs),
].filter(value => value !== undefined).join(' · ')
const open = (): void => {
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
@@ -222,7 +269,7 @@ function CatalogRows({
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')}
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
className={css.row}
onClick={open}
@@ -247,7 +294,7 @@ function CatalogRows({
<span className={css.label}>{label}</span>
<span className={css.summary}>{secondary}</span>
</span>
{time !== undefined && <span className={css.time}>{time}</span>}
{metrics !== '' && <span className={css.metrics}>{metrics}</span>}
</div>
</div>
{isExpanded && !knownLeaf && (
@@ -300,6 +347,7 @@ export function SubagentCatalogAction({
const summaries = useSessions(state => state.byId)
const catalog = catalogs[sessionId]
const [open, setOpen] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -328,7 +376,10 @@ export function SubagentCatalogAction({
const changeOpen = (next: boolean, restoreFocus = false): void => {
setOpen(next)
if (next) observeCatalog(sessionId, true)
if (next) {
setNow(Date.now())
observeCatalog(sessionId, true)
}
else closeAllCatalogs()
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
@@ -368,6 +419,12 @@ export function SubagentCatalogAction({
return () => { document.removeEventListener('pointerdown', closeOutside) }
}, [open])
useEffect(() => {
if (!open || !descendants.running) return
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { clearInterval(timer) }
}, [open, descendants.running])
useEffect(() => () => {
for (const parentSessionId of observedCatalogs.current) {
setCatalogOpenRef.current(parentSessionId, false)
@@ -443,7 +500,7 @@ export function SubagentCatalogAction({
summaries={summaries}
expanded={expanded}
level={1}
now={Date.now()}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}

View File

@@ -11,6 +11,7 @@ import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -217,42 +218,56 @@ describe('SubagentCatalogAction', () => {
})
})
it('renders compact activity times across every unit and clamps future timestamps', () => {
it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => {
const now = 2_000_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
vi.useFakeTimers()
vi.setSystemTime(now)
const rows = [
['future', now + minute],
['minutes', now - 2 * minute],
['hours', now - 2 * hour],
['days', now - 2 * day],
['months', now - 60 * day],
['years', now - 2 * 365 * day],
['running', 'running', 65_000, now - 5_000, now],
['finished', 'inactive', 3_723_000, undefined, now - 60_000],
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000],
] as const
const entries = rows.map(([id]) => ({
const entries = rows.map(([id, activity]) => ({
kind: 'child' as const,
id: id as SessionId,
mode: 'continuable' as const,
label: id,
activity: 'inactive' as const,
activity,
hasChildren: false,
}))
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
id,
summary(id as SessionId, updatedAt),
])) as Record<SessionId, SessionSummary>
const summaries = Object.fromEntries(rows.map(([id, activity, settledMs, activeSince, updatedAt]) => {
const childId = id as SessionId
return [id, {
...summary(childId, updatedAt),
parentId: PARENT,
origin: 'subagent' as const,
running: activity === 'running',
projectionValues: {
subagentTiming: {
settledMs,
...(activeSince === undefined ? {} : { activeSince }),
},
tokenUsage: {
uncachedInputTokens: 1_000,
outputTokens: 200,
cacheReadTokens: 3_000,
cacheWriteTokens: 400,
},
},
}]
})) as Record<SessionId, SessionSummary>
const input = props(catalog({ entries }), {}, summaries)
render(<SubagentCatalogAction {...input} />)
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ }))
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy()
await vi.advanceTimersByTimeAsync(1_000)
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy()
})
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {

View File

@@ -26,6 +26,12 @@
{
"path": "../ui-slots"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../support/invariants"
}