Merge remote-tracking branch 'origin/master' into fix/web-sources-scroll

This commit is contained in:
Chinesezjc
2026-08-03 15:41:09 +08:00
66 changed files with 1399 additions and 160 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/runtime/README.md
README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df
README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77
README.md: 82f1bc95a6128245f88f01a0de0849494cb98359
README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Workspace 与 Session 列表

View File

@@ -3,10 +3,13 @@
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
/** Host list summary enriched with the latest mux-projected durable title. */
export interface TitledSessionSummary extends SessionSummary {
title?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
@@ -21,6 +24,8 @@ export interface SessionListEntry {
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
cwd?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */

View File

@@ -846,10 +846,14 @@ export class SessionManager {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
// value; the bespoke session/title frame is retired).
const title = this.projectionStores.get(summary.sessionId)?.get('title')
return typeof title === 'string' && title !== ''
? { ...summary, title }
: summary
const projectionStore = this.projectionStores.get(summary.sessionId)
const title = projectionStore?.get('title')
const projectionValues = projectionStore?.values()
return {
...summary,
...(typeof title === 'string' && title !== '' ? { title } : {}),
...(projectionValues === undefined ? {} : { projectionValues }),
}
})
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
@@ -860,6 +864,7 @@ export class SessionManager {
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
&& prev.projectionValues === entry.projectionValues
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -74,6 +74,7 @@ interface Channel {
export class ProjectionValueStore {
private readonly rows = new Map<string, Row>()
private readonly channels = new Map<string, Channel>()
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
private readonly anyNotifier = new Notifier(() => {})
@@ -98,6 +99,19 @@ export class ProjectionValueStore {
return this.rows.get(key)?.value
}
/**
* Read every current projection value as one reference-stable snapshot.
* @returns The same frozen value map until a row changes.
*/
values(): Readonly<Partial<SessionProjectionMap>> {
if (this.valuesCache === undefined) {
this.valuesCache = Object.freeze(Object.fromEntries(
[...this.rows].map(([key, row]) => [key, row.value]),
))
}
return this.valuesCache
}
/**
* Subscribe to any-key changes (microtask-batched) — the manager's list
* rebuild channel.
@@ -160,6 +174,7 @@ export class ProjectionValueStore {
}
private changed(key: string): void {
this.valuesCache = undefined
this.channels.get(key)?.notifier.markDirty()
this.anyNotifier.markDirty()
}

View File

@@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
@@ -57,6 +58,8 @@ export interface SessionSummary {
*/
blank: boolean
updatedAt: number
/** Current host-computed projection values retained by the object layer. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/**
@@ -613,6 +616,9 @@ export class SessionsService implements ISessions {
waitingApproval: entry.waitingApproval,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),

View File

@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
const store = new ProjectionValueStore()
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
})
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
const store = new ProjectionValueStore()
const empty = store.values()
expect(store.values()).toBe(empty)
store.apply('test/marks', { marks: ['a'] }, 1)
const populated = store.values()
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
expect(populated).not.toBe(empty)
expect(store.values()).toBe(populated)
})
})
describe('Session tail-page seeding', () => {
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
})
it('projects every retained value into list rows with stable snapshot identity', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onList = () => Promise.resolve(ok({
items: [{
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
projections: {
asOfSeq: 2,
values: { 'test/marks': { marks: ['baseline'] } },
},
}],
}) as never)
await manager.refreshList()
const baseline = manager.getListSnapshot().items[0]?.projectionValues
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
manager.handleMuxEnvelope({
rpcId: 'p2' as never,
payload: {
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
value: { marks: ['live'] }, seq: 3,
} as never,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.projectionValues)
.toEqual({ 'test/marks': { marks: ['live'] } })
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
})
it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)

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-conversation/README.md
README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439
README.zh.md: 268e05a806db1468ba689608c178646af132fe25
README.md: 728d67aaccee609d5f28ee8744c1f11ca74b040a
README.zh.md: eec1d36b9758dddcf63eb75c3a74c51b4afb0a72

View File

@@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).

View File

@@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。

View File

@@ -20,6 +20,63 @@
border: 1px solid var(--dsw-alias-border-l1);
}
/* A bash execution error can settle without terminal-card material (for
example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback
so the original command and full error remain available from this keyed row. */
.ioCard {
display: flex;
flex-direction: column;
margin: 4px 0 4px 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block-small);
}
.ioSection {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 14px;
align-items: baseline;
padding: 12px 16px;
max-height: 150px;
overflow-y: auto;
}
.ioSection::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.ioSection::-webkit-scrollbar-track {
margin: 6px 0;
}
.ioLabel {
position: sticky;
top: 0;
align-self: start;
color: var(--dsw-alias-label-caption);
}
.ioDivider {
flex: none;
height: 1px;
background: var(--dsw-alias-border-l2);
}
.ioText {
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--dsw-alias-label-secondary);
}
.ioText[data-error] {
color: var(--dsw-alias-state-error-primary);
}
/* ToolRow's unified expand interaction, replicated per the registrant
posture: pointer on the expandable row (the icon→chevron hover preview is
the affordance, no row fill). */

View File

@@ -2,8 +2,10 @@
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
//
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
// A bash call normally declares the terminal render intent, so this row renders
// the command's own output through TerminalBlock. Execution failures that
// settle without terminal material use the bounded generic IN/OUT fallback —
// both are expand-gated exactly like
// ToolRow's unified interaction: collapsed by default, the whole summary row
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
// summary stays inline while open),
@@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* whole row toggling the command's terminal or generic error card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
@@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
: model.state
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
// Execution failures (for example cancellation before the process reports a
// terminal result) use the generic presenter. Keep their recorded args and
// full error reachable instead of collapsing the row to the first line.
const genericError = terminal === null
&& model.state === 'error'
&& (model.body !== null || model.output !== null)
const expandable = terminal !== null || genericError
const open = expanded && expandable
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
@@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
{failureLine ?? terminal?.description ?? model.summary}
</span>
</div>
{terminal !== null && open && (
{open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{terminal !== null
? (
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
)
: (
<div className={css.ioCard}>
{model.body !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{model.body}</span>
</div>
)}
{model.body !== null && model.output !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{model.output !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error>
{model.output}
</span>
</div>
)}
</div>
)}
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>

View File

@@ -414,6 +414,28 @@ describe('BashRow terminal card', () => {
}))} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
})
it('expands a generic execution error to its original args and full output', () => {
const view = render(<BashRow {...rowProps(settled({
content: [{ type: 'text', text: 'Error: command aborted' }],
isError: true,
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
resultView: { card: 'generic' },
}))} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.getAttribute('role')).toBe('button')
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('IN')).toBeTruthy()
expect(view.getByText('OUT')).toBeTruthy()
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
})
})

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: cb210b219a8c66985eb4e1370468372eed9614b4
README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c

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 and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. 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 its same-cut `active.through`, never by newer session metadata. 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; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. 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()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、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

@@ -48,6 +48,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": {
@@ -59,6 +61,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,28 @@
}
.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 {

View File

@@ -2,7 +2,8 @@ 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,
@@ -10,6 +11,8 @@ import {
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
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]
@@ -57,23 +60,115 @@ 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,
/** 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,
activity: 'running' | 'inactive',
now: number,
t: TranslateNS<typeof NS>,
): 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 t('time.justNow')
if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) })
if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) })
if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) })
if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) })
return t('time.years', { n: Math.floor(diff / (365 * day)) })
): number | undefined {
if (summary === undefined) return undefined
const timing: SessionProjectionMap['subagentTiming'] | undefined
= summary.projectionValues?.subagentTiming
if (timing === undefined) return undefined
if (timing.active === undefined) return timing.settledMs
const end = activity === 'running'
? now
: timing.active.through
return timing.settledMs + Math.max(0, end - timing.active.since)
}
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 totalMinutes = Math.floor(totalSeconds / 60)
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: totalHours,
minutes: String(minutes).padStart(2, '0'),
seconds: String(seconds).padStart(2, '0'),
})
}
if (totalMinutes > 0) {
return t('duration.minutes', {
minutes: totalMinutes,
seconds: String(seconds).padStart(2, '0'),
})
}
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. */
@@ -142,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 && (
@@ -178,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>
@@ -201,7 +299,24 @@ function CatalogRows({
const secondary = [summary?.title, mode, activity]
.filter(value => value !== undefined)
.join(' · ')
const time = relativeTime(summary?.updatedAt, now, t)
const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage)
const durationMs = activityDuration(
summary,
entry.activity,
now,
)
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 })
@@ -233,14 +348,14 @@ 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}
onKeyDown={handleKey}
>
{knownLeaf
? <span className={css.disclosureSpace} />
? reserveDisclosure && <span className={css.disclosureSpace} />
: (
<button
type="button"
@@ -258,7 +373,19 @@ 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}>
{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>
</div>
{isExpanded && !knownLeaf && (
@@ -313,6 +440,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)
@@ -355,7 +483,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() })
}
@@ -395,6 +526,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)
@@ -471,7 +608,7 @@ export function SubagentCatalogAction({
summaries={summaries}
expanded={expanded}
level={1}
now={Date.now()}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}

View File

@@ -8,12 +8,17 @@ export const zh = {
'diagnostic.corrupt': '会话记录损坏',
'diagnostic.unsupported': '子代理记录版本不受支持',
'diagnostic.unavailable': '会话记录暂不可用',
'time.justNow': '刚刚',
'time.minutes': '{n}分钟',
'time.hours': '{n}小时',
'time.days': '{n}天',
'time.months': '{n}个月',
'time.years': '{n}年',
'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,12 +45,17 @@ export const en: Record<SubagentKey, string> = {
'diagnostic.corrupt': 'corrupted session record',
'diagnostic.unsupported': 'unsupported subagent record version',
'diagnostic.unavailable': 'session record temporarily unavailable',
'time.justNow': 'just now',
'time.minutes': '{n}m',
'time.hours': '{n}h',
'time.days': '{n}d',
'time.months': '{n}mo',
'time.years': '{n}y',
'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',

View File

@@ -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,
@@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -150,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({
@@ -176,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} />)
@@ -239,42 +254,99 @@ 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 - 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 entries = rows.map(([id]) => ({
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,
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, activeThrough, 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 || activeThrough === undefined
? {}
: { active: { since: activeSince, through: activeThrough } }),
},
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: /6 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
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()
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秒')
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()
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', () => {

View File

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