mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(web): add nested subagent conversations
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: 400931ba38ac0b3c148d0554ae01d7e8089c2714
|
||||
2026-07-27-web-subagent-conversations.zh.md: c8dd8acca8ff9c33148e1b34b9d28fc9158e83dd
|
||||
@@ -0,0 +1,120 @@
|
||||
# Agent Note: Web subagent catalog and human continuation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-web-subagent-conversations.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Continuable background subagents have durable identities, persisted transcripts, inbox-driven Activations, and a direct-child catalog. The model can discover and continue them through `list_agents` and `send_message`, but the Web client has no equivalent product path. Its session tree knows only lineage, so it cannot distinguish a continuable subagent from an ordinary fork, and opening an inactive session through the ordinary history path resumes an Agent merely to display it.
|
||||
|
||||
Treating a child as an ordinary Web session would violate the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md). Ordinary `session.history` and `session.prompt` address an Agent directly; a continuable child must display from persistence without materializing an Activation and accept human input through `SubagentService.followup()` so the Agent inbox owns ordering while the continuation manager owns authorization, cold resume, durability, and teardown.
|
||||
|
||||
The UI also needs to preserve the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) semantics. `running` and `inactive` are live-preferred session-activity snapshots, not success outcomes or delivery promises; corrupt, unsupported, and unavailable children remain explicit diagnostics; and only direct continuable children belong in one catalog response.
|
||||
|
||||
## Decision
|
||||
|
||||
The Web product will expose direct continuable children from the selected session's title header and let a user open their persisted conversations. A child conversation will reuse the existing event fold, message rendering, streaming path, title, and input chrome, but its history and prompt operations will use a dedicated subagent address `{ parentSessionId, childSessionId }` rather than the ordinary session RPCs.
|
||||
|
||||
Human input will call `ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', rpcId }, signal })`. A resident Activation admits the message into its Agent inbox; an absent Activation cold-resumes the same durable Session before inbox admission. The host will never resume the parent merely to enable interaction: the exact direct-parent Agent must already be live to authorize delivery. When it is absent, the child remains a read-only transcript.
|
||||
|
||||
This proposal covers Web discovery, transcript viewing, and human continuation. It does not turn a subagent into a user-owned conversation that survives independently of its parent; that product belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
|
||||
|
||||
## Design context
|
||||
|
||||
The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are the interaction and visual references for this proposal. They are non-normative presentation context: this Agent Note owns lifecycle, wire, failure, and acceptance contracts, and a later design-file edit does not change those contracts without a corresponding note update.
|
||||
|
||||
| Design intent | Contract in this proposal |
|
||||
| --- | --- |
|
||||
| The session header shows a subagent count and opens a compact list. | The header action shows the direct durable catalog, including healthy entries and explicit diagnostic rows. |
|
||||
| Selecting a row opens a child with ordinary conversation chrome, title, transcript, and composer. | The child reuses the conversation UI, but history and input route through the addressed subagent RPCs. The composer sends human follow-ups only while the exact parent Agent is live and otherwise explains the read-only state. |
|
||||
| Nested agents can be explored progressively. | Expanding a row loads only that child's direct catalog and inserts it as the next tree level. The client never materializes an eager recursive catalog. |
|
||||
| Rows show labels, activity dots, and relative time, while the sidebar omits the old subagent tree. | Labels and coarse `running` or `inactive` activity come from the catalog. An optional log-backed title and relative last-activity time come from the ordinary session summary; they are not Activation outcome or duration. Complete sidebar de-duplication remains deferred. |
|
||||
|
||||
## Product contract
|
||||
|
||||
Every selected session may show a header action labeled `<N> subagents`, where `N` counts healthy `kind: 'child'` entries returned by `listChildren()` and excludes diagnostics. The action is absent when the complete response has neither a child nor a diagnostic. Opening it shows the direct children in service order, with the durable creation label, an activity indicator, and a disabled diagnostic row for every corrupt, unsupported, or unavailable candidate.
|
||||
|
||||
`running` means the child Session is live in the host's logical session corpus. `inactive` means the child exists only in persistence and may be resumed by a later message. The UI renders them as `正在处理` and `已完成`, but the latter is only presentation for inactivity, not a succeeded, failed, or cancelled outcome. A row may show the ordinary session summary's relative `updatedAt` as a last-activity hint, but it does not present that value as Activation elapsed time. A listed child's `running`/`inactive` value updates live: the catalog consumer flips it in place from the same `host/session-status` frame that drives ordinary session `running`, so a child settling `running → inactive` needs no navigation or refetch. That frame carries only liveness, so a child's `label`, diagnostic reason, health transition, or membership change is not in it and still resolves through a `subagent.list` refetch; cross-process settlement and the window before a refetch lands remain snapshot-stale, and `subagent.prompt`'s result, not the activity label, is delivery-time authority.
|
||||
|
||||
Selecting a healthy row opens the child in the existing conversation region, whose resident header shows the child title. The header dropdown is an ARIA tree: expanding a row loads that child's direct catalog lazily, and further expansion repeats the same operation at any depth. Every visible branch retains its own direct-parent address and catalog lifecycle; collapsing a branch or closing the tree stops membership consumption for that branch and its expanded descendants.
|
||||
|
||||
The composer is enabled only when the catalog adapter reports that the exact parent Agent is live. Submitting clears the draft optimistically and restores it on an explicit not-delivered response, matching the ordinary composer failure behavior. A successful response carries the accepted inbox `messageId`; it does not expose whether an Activation was resident or cold-resumed, or promise that the resulting turn completed successfully.
|
||||
|
||||
When the parent is not live, the transcript stays readable and the composer presents a read-only explanation. The adapter does not auto-resume the parent, because a replacement Agent is not the retained owner of an existing Activation. Visiting the parent through its ordinary session path may make a parent Agent live for a later new Activation, but child navigation itself has no such side effect.
|
||||
|
||||
The ordinary Stop action is hidden for subagent conversations in this version. `session.cancel` would bypass continuation-manager ownership and child-first teardown, while the subagent service exposes no per-message or per-Activation cancellation operation after inbox acceptance. A correct cancellation control requires a separately designed Activation observation and authority surface.
|
||||
|
||||
## Host adapter and wire contract
|
||||
|
||||
`@deepseek-ai/dsh-host-apiproxy` will own a browser-safe `subagents` domain alongside `sessions`, with zod-validated unary methods registered through the existing `RpcMethodMap` and fetch carrier:
|
||||
|
||||
- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, and returns the complete ordered entry array plus whether `ctx.agents.get(parentSessionId)` currently resolves the required live parent. Parent availability is a UI hint; `subagent.prompt` remains the delivery-time authority.
|
||||
- `subagent.history` takes the direct parent id, child id, and the ordinary history page arguments. It first verifies that the child is a healthy entry in that parent's durable catalog, then reads the child through `ctx.sessionQuery` without publishing or resuming an Agent. It returns the same raw-event-plus-render-intent shape and message-aligned pagination contract used by ordinary history so the browser uses one fold.
|
||||
- `subagent.prompt` takes the direct parent id, child id, and `ContentBlock[]`. It requires the exact live parent from `ctx.agents`, calls `ctx.subagents.followup()` with human attribution, the request's rpcId, and the operation signal, and returns `{ messageId }`. The adapter never bypasses the service through direct `agent.followup()`, `agent.steer()`, or generic `ctx.agents.resume()` calls.
|
||||
|
||||
The gateway maps missing-parent, catalog diagnostic, not-resumable, unauthorized, ownership-conflict, cancelled, and not-delivered failures to typed RPC errors without leaking the model-hidden descriptor. A race after `subagent.list` may still make `subagent.prompt` fail; the prompt result, not the earlier availability bit or `running` activity, is authoritative.
|
||||
|
||||
The mux remains the live event path. A persisted child contributes no live subscription merely because its history is viewed. When `subagent.prompt` starts a cold-resume Activation, publication makes the existing mux subscribe to that child and the browser reconciles subsequent events by sequence. Reconnect rebuilds an addressed child from `subagent.history`, not ordinary `session.history`.
|
||||
|
||||
The adapter belongs in `dsh-host-apiproxy`, which owns the channel-independent contract and host implementation. `dsh-host-webserver` remains only the HTTP/SSE carrier and gains no subagent behavior. The browser imports the protocol through the existing connection client and never reaches host `ctx` directly, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
|
||||
|
||||
## Client object layer and presentation
|
||||
|
||||
The React-free client runtime will own durable catalog snapshots, in-flight refreshes, subagent addresses, and prompt/history routing. Opening a catalog child records its `{ parentSessionId, childSessionId }` address before opening the resident `Session`; that Session uses `subagent.history` and `subagent.prompt`, while ordinary sessions keep their existing transport. A child address discovered from the catalog is the only browser fact that selects this route; `parentId` alone is insufficient because ordinary forks share the same lineage field.
|
||||
|
||||
Catalog data is projected through the existing sessions snapshot consumed by `useSessions`, rather than placed in a component store or exposed through a feature-defined hook. The tree reads ordinary session summaries from the same snapshot for optional title and last-activity presentation. While the root or an expanded descendant catalog is open, its consumer attaches to the existing host-frame fan-out, as the workspaces manager already does: a `host/session-status` frame naming a listed child flips that child's `running`/`inactive` activity in place through the same optimistic mutation path as ordinary session `running`, with no `subagent.list` refetch. A `host/session-added` frame whose parent matches an open branch triggers one debounced, single-flight `subagent.list` refetch to admit the new member and its label and descriptor. Component-local state owns dropdown visibility, expanded branch ids, and keyboard focus.
|
||||
|
||||
`ui-conversation` will declare and render a session-scoped `conversation.session.header.actions` list slot beside the title. The existing `@deepseek-ai/dsh-client-ui-subagent` plugin will register the catalog trigger and dropdown there, and will provide the subagent-specific read-only composer presentation through the conversation's existing composition points. Extending that package keeps one Web subagent feature owner; its current `@label` reference source remains plain-text model input and does not acquire continuation semantics in this proposal.
|
||||
|
||||
The presentation follows existing styling and accessibility rules: Chinese product copy, token-only light/dark colors, tree and treeitem semantics, ArrowRight/ArrowLeft branch disclosure, linear ArrowUp/ArrowDown navigation, focus returned to the trigger on close, activity communicated by text as well as color, and disabled diagnostic rows that remain readable. The component receives only derived props and injected callbacks; it never receives `ctx` or a host service.
|
||||
|
||||
## Default Web assembly
|
||||
|
||||
The `dsh web` composition will mount the SQLite session-query provider beside JSONL persistence, with its database under the configured session persistence root. Both in-process spawn and fork delegation tools will select `backgroundMode: continuable`; that route uses the continuation manager and Agent inbox rather than Tasks. The host catalog projection excludes one-shot entries, while remote ACP runs remain outside it because they publish no local child Session.
|
||||
|
||||
The default Web composition will mount the model-facing `send_message` and `list_agents` adapters for coordinator parity, but the GUI will not call those tools; it will call the shared `SubagentService` through the host RPC adapter. Their model-visible schemas and snapshots will be verified independently from the GUI transcript.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reuse ordinary `session.history` and `session.prompt`.** Rejected because both paths resume or drive the child Agent directly and bypass continuation-manager authority and inbox admission. Display must be non-activating, and human input must share the same follow-up or cold-resume operation as parent input.
|
||||
|
||||
**Put the adapter in `dsh-host-webserver`.** Rejected because subagent listing and continuation are channel-independent client capabilities. The webserver carries validated RPC calls and SSE frames; it does not own harness services or business routing.
|
||||
|
||||
**Create another UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the natural feature boundary for catalog, navigation, and human continuation. The conversation package owns only the header slot and generic conversation chrome.
|
||||
|
||||
**Auto-resume an absent parent when the user submits.** Rejected because human continuation is authorized through the exact live parent Agent. Silent parent activation also turns a child-page gesture into an unexpected parent lifecycle mutation.
|
||||
|
||||
**Expose ordinary cancel immediately.** Rejected because Agent cancellation bypasses continuation-manager ownership and child-first teardown. A correct cancel control needs the current Activation identity plus an owner-authorized operation that can define whether it cancels one inbox message, one turn, or the residency epoch, including Activations the GUI did not start.
|
||||
|
||||
**Add Activation outcome and timing fields to the durable catalog.** Deferred because the catalog intentionally describes durable child identity and coarse live presence. A durable Activation record is a separate backend contract and should not be inferred from session presence or the last `turn/end`.
|
||||
|
||||
**Build an eager recursive tree.** Rejected because `listChildren()` is direct and can scan every candidate log. The presentation composes a recursive tree from lazy direct-child queries, preserving each catalog's ordering and diagnostic semantics without multiplying work across an unseen hierarchy.
|
||||
|
||||
**Remove every subagent from the sidebar session tree.** Deferred because `session.list` exposes lineage but not continuable identity, and the catalog is parent-addressed rather than a cheap global classifier. Hiding all child sessions would also hide ordinary forks. The header becomes the authoritative subagent entry point, while complete sidebar de-duplication awaits a scalable durable projection.
|
||||
|
||||
**Push catalog changes as a dedicated server stream.** Deferred in favor of reusing the existing `host/session-status` and `host/session-added` fan-out. A `subagent.catalog` delta frame would make membership and diagnostics fully live without any refetch, but it is a new host wire contract and a real-time projection over the durable catalog — exactly the derived index the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) leaves to measured scale. The first version flips activity from the existing liveness frame and refetches only on membership change.
|
||||
|
||||
**Let the child remain independently interactive after the parent disappears.** Rejected as a reinterpretation of continuable background work. Independent lifetime, user ownership, and merge-back semantics belong to interactive side sessions.
|
||||
|
||||
## Testing
|
||||
|
||||
- The default Web composition creates continuable spawn and fork children and mounts the session-query and continuation surfaces required for durable listing and human follow-up.
|
||||
- A selected parent displays its complete direct-child catalog in stable service order, including healthy labels/activities and explicit disabled diagnostics; an empty healthy catalog contributes no header action. Expanding a child fetches its direct catalog, displays the next level with tree semantics and direct-parent addresses, and recursively closes descendant consumers when its branch closes.
|
||||
- Opening a persisted child renders its event transcript and title header without publishing or resuming either the child or its parent.
|
||||
- A child with a live parent submits through `SubagentService.followup()` with human attribution; the UI receives the accepted inbox `messageId` and receives the resulting child events through the existing mux and fold.
|
||||
- A listed `running` child settling to `inactive` updates its activity in place from the live frame stream without a `subagent.list` refetch; a newly created direct child appears after one debounced refetch.
|
||||
- A child whose parent is absent remains readable and rejects input without auto-resuming the parent. No child history, prompt, or stop gesture calls the ordinary Agent APIs.
|
||||
- Refresh and reconnect rebuild an addressed child through the subagent history path without duplicating events or losing events emitted across cold-resume publication.
|
||||
- Host protocol tests pin schemas, id echoing, direct-parent validation, non-activating history, live-parent enforcement, error mapping, and inbox-message acknowledgement. Client object tests pin catalog/address state and transport selection; jsdom tests pin the header tree, lazy nested disclosure, diagnostics, enabled/read-only composer states, keyboard behavior, and draft restoration.
|
||||
- A keyless assembled Web snapshot demonstrates a settled continuable child plus a descriptor-bearing persisted grandchild, progressive catalog expansion without Activation, opening from persistence, and accepting one human follow-up into a cold-resumed Activation's inbox.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The feature is built on the continuation and durable-catalog contracts; changes to their stacked implementation may require the host adapter and fixtures to move with them before this proposal can ship.
|
||||
- `listChildren()` may rescan persistence and child logs (O(D×C + ΣLᵢ) per the durable catalog). Activity changes are therefore applied live from `host/session-status` without a refetch; only membership changes trigger a debounced, single-flight `subagent.list` reload, so the scan does not run on every render or status frame.
|
||||
- Parent availability and child activity are process-local snapshots. Publication, Activation disposal, another sender, or another process may win after listing; explicit prompt failure remains normal behavior rather than an invariant violation.
|
||||
- A child Activation may publish between history fetch and mux subscription. The existing sequence reconciliation must be proven against this cold-to-live, subagent-specific open path.
|
||||
- Switching the default Web delegation tools to continuable background mode changes the model-visible acknowledgement and durability requirements for `run_in_background`; snapshot coverage must land with the assembly change.
|
||||
- Keeping subagents in the sidebar temporarily creates two navigation paths. Removing the duplicate safely requires a later durable classification or global projection rather than presentation-only filtering.
|
||||
- The feature has no correct cancellation button, durable outcome, Activation duration, deletion, pagination for the catalog, or independently interactive offline child. The UI must not imply those capabilities; its relative time is only the session summary's last-activity hint.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Agent Note: Web subagent 目录与用户继续交互
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-web-subagent-conversations.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
可继续的后台 subagent 具有持久化身份、持久化 transcript(文本记录)、由 inbox 驱动的 Activation 以及直接 child 目录。模型可以通过 `list_agents` 与 `send_message` 发现并继续这些 subagent,但 Web 客户端没有对应的产品路径。它的会话树只知道谱系,因此无法区分可继续的 subagent 与普通 fork;通过普通历史路径打开 inactive 会话时,还会仅为展示而恢复 Agent。
|
||||
|
||||
将 child 当作普通 Web 会话会违反[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)。普通 `session.history` 与 `session.prompt` 会直接寻址 Agent;可继续的 child 必须从持久化存储中展示且不物化 Activation,并通过 `SubagentService.followup()` 接收用户输入,使 Agent inbox 负责排序,继续执行 manager 负责授权、cold resume、持久性与清理。
|
||||
|
||||
UI 还需要保留[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的语义。`running` 与 `inactive` 是采用实时优先规则的会话活动快照,不代表成功结果或投递承诺;损坏、不受支持及不可用的 child 仍以显式 diagnostic 呈现;一次目录响应只包含直接可继续 child。
|
||||
|
||||
## 决策
|
||||
|
||||
Web 产品将在选中会话的标题栏公开直接可继续 child,并允许用户打开其持久化对话。child 对话会复用现有的事件 fold、消息渲染、流式输出路径、标题与输入区 chrome,但历史和提示词操作将使用专用的 subagent 地址 `{ parentSessionId, childSessionId }`,而非普通会话 RPC。
|
||||
|
||||
用户输入将调用 `ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', rpcId }, signal })`。驻留的 Activation 会把消息准入其 Agent inbox;没有 Activation 时,则先对同一个持久化 Session 执行 cold resume,再完成 inbox 准入。宿主绝不会仅为启用交互而恢复 parent:确切的直接 parent Agent 必须已存活才能授权投递。如果它不存在,child 会保持只读 transcript。
|
||||
|
||||
本提案涵盖 Web 端发现、transcript 查看与用户继续交互。它不会把 subagent 变成脱离 parent 后仍可独立存续的用户自有对话;这类产品属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。
|
||||
|
||||
## 设计上下文
|
||||
|
||||
Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是本提案的交互与视觉参考。它们只是非规范性的呈现上下文:生命周期、协议、失败与验收契约以本记录为准;后续只修改设计文件而不相应更新本记录,不会改变这些契约。
|
||||
|
||||
| 设计意图 | 本提案中的契约 |
|
||||
| --- | --- |
|
||||
| 会话页头显示 subagent 数量,并可打开紧凑列表。 | 页头操作显示直接持久化目录,其中包括健康条目和显式 diagnostic 行。 |
|
||||
| 选择条目后,系统会使用普通对话 chrome、标题、transcript 与输入框打开 child。 | child 复用对话 UI,但历史和输入通过已寻址的 subagent RPC 路由。只有确切的 parent Agent 存活时,输入框才发送用户后续消息;否则会说明当前为只读状态。 |
|
||||
| 用户可以逐层浏览嵌套 agent。 | 展开某一行时,只加载该 child 的直接目录,并将其插入为下一层树节点。客户端绝不会物化预先加载的递归目录。 |
|
||||
| 条目显示 label、活动状态点与相对时间,侧边栏则移除原有 subagent 树。 | label 与粗粒度的 `running` 或 `inactive` 活动状态来自目录。由日志支撑的可选 title 与相对最近活动时间来自普通会话摘要;它们不是 Activation 结果或耗时。侧边栏完全去重仍然暂缓。 |
|
||||
|
||||
## 产品契约
|
||||
|
||||
每个选中会话都可以显示标为 `<N> subagents` 的页头操作,其中 `N` 只计算健康的 `kind: 'child'` 条目,这些条目由 `listChildren()` 返回,且计数不包含 diagnostic。完整响应既无 child 也无 diagnostic 时,不显示该操作。打开后,界面按服务顺序显示直接 child,每行包含持久化创建 label 与活动指示;每个损坏、不受支持或不可用的候选则显示为禁用的 diagnostic 行。
|
||||
|
||||
`running` 表示 child Session 存活于宿主的逻辑会话语料库中。`inactive` 表示 child 仅存在于持久化存储中,后续消息可以将其恢复。UI 分别将二者呈现为「正在处理」与「已完成」,但后者只是在呈现 inactive,而非成功、失败或已取消结果。条目可以把普通会话摘要中的 `updatedAt` 显示为相对时间,用作最近活动提示,但不会把该值呈现为 Activation 耗时。已列出 child 的 `running`/`inactive` 值会实时更新:目录消费方从驱动普通会话 `running` 的同一条 `host/session-status` 帧就地翻转它,因此 child 从 `running` 结算为 `inactive` 无需导航或重新拉取。该帧只携带存活状态,因此 child 的 `label`、diagnostic 原因、健康状态迁移或成员变化都不在其中,仍需通过 `subagent.list` 重新拉取解析;跨进程结算以及重新拉取落地前的窗口仍为快照陈旧状态,投递时权威依据是 `subagent.prompt` 的结果,而非活动文案。
|
||||
|
||||
选择健康条目后,现有对话区域会打开对应 child,常驻标题栏显示该 child 的标题。页头下拉菜单是一棵 ARIA 树:展开某一行会懒加载该 child 的直接目录,继续展开则可在任意深度重复同一操作。每个可见分支都保留自身的直接 parent 地址和目录生命周期;折叠分支或关闭树时,会停止消费该分支及其已展开后代的成员关系。
|
||||
|
||||
只有在目录适配器报告确切的 parent Agent 已存活时,输入框才会启用。提交时,界面先乐观清空草稿;收到明确的未送达响应后再恢复草稿,与普通输入框的失败行为一致。成功响应携带已接纳的 inbox `messageId`;它不暴露 Activation 原本驻留还是经 cold resume 物化,也不承诺相应轮次已成功完成。
|
||||
|
||||
parent 不存活时,transcript 仍可阅读,输入框则显示只读说明。适配器不会自动恢复 parent,因为替代 Agent 并不是现有 Activation 保留的 owner。通过普通会话路径访问 parent,可能使一个 parent Agent 存活,以便后续创建新 Activation;但 child 导航本身不会产生这种副作用。
|
||||
|
||||
这一版本会在 subagent 对话中隐藏普通 Stop 操作。`session.cancel` 会绕过继续执行 manager 的所有权与 child-first 清理,而 subagent 服务在 inbox 接受后不公开逐消息或逐 Activation 取消操作。正确的取消控件需要另行设计 Activation 观测与授权表层。
|
||||
|
||||
## 宿主适配器与协议契约
|
||||
|
||||
`@deepseek-ai/dsh-host-apiproxy` 将拥有浏览器安全的 `subagents` 域,与 `sessions` 并列,并通过现有 `RpcMethodMap` 与 fetch 载体注册由 zod 校验的一元方法:
|
||||
|
||||
- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目数组,并说明 `ctx.agents.get(parentSessionId)` 当前能否解析出所需的存活 parent。parent 可用性只是 UI 提示;消息投递时仍以 `subagent.prompt` 为准。
|
||||
- `subagent.history` 接受直接 parent id、child id 与普通历史页参数。它首先确认 child 是该 parent 持久化目录中的健康条目,再通过 `ctx.sessionQuery` 读取 child,且不发布或恢复 agent。它返回与普通历史相同的原始事件加渲染意图形态,以及按消息对齐的分页契约,使浏览器只使用一套 fold。
|
||||
- `subagent.prompt` 接受直接 parent id、child id 与 `ContentBlock[]`。它要求 `ctx.agents` 中存在确切的存活 parent,使用用户来源信息、请求的 rpcId 与操作 signal 调用 `ctx.subagents.followup()`,并返回 `{ messageId }`。适配器绝不会绕过该服务而直接调用 `agent.followup()`、`agent.steer()` 或通用 `ctx.agents.resume()`。
|
||||
|
||||
网关会将 parent 缺失、目录 diagnostic、不可恢复、未授权、所有权冲突、已取消及未送达等失败映射为类型化 RPC 错误,且不会泄露对模型隐藏的描述符。`subagent.list` 之后的竞态仍可能导致 `subagent.prompt` 失败;权威依据是提示词操作的结果,而非更早的可用性位或 `running` 活动状态。
|
||||
|
||||
mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生实时订阅。`subagent.prompt` 启动 cold resume Activation 时,发布操作会让现有 mux 订阅该 child,浏览器则按序号归并后续事件。重新连接时,系统通过 `subagent.history` 重建已寻址的 child,而不是调用普通 `session.history`。
|
||||
|
||||
适配器属于 `dsh-host-apiproxy`,由它负责通道无关的契约与宿主实现。`dsh-host-webserver` 仍然只作为 HTTP/SSE 载体,不增加任何 subagent 行为。浏览器通过现有连接客户端导入协议,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
|
||||
|
||||
## 客户端对象层与呈现
|
||||
|
||||
不依赖 React 的客户端运行时将负责持久化目录快照、进行中的刷新、subagent 地址及提示词/历史路由。打开目录中的 child 时,系统会先记录其 `{ parentSessionId, childSessionId }` 地址,再打开常驻的 `Session`;该 Session 使用 `subagent.history` 与 `subagent.prompt`,普通会话则保持现有传输路径。只有从目录中发现的 child 地址,才能作为浏览器选择这条路由的事实;单凭 `parentId` 不足以判断,因为普通 fork 也使用同一个谱系字段。
|
||||
|
||||
目录数据通过 `useSessions` 消费的现有会话快照投影,而不会放入组件 store,也不会通过功能自定义钩子公开。树还从同一份快照读取普通会话摘要,用于显示可选 title 与最近活动时间。根目录或某个已展开后代的目录打开期间,其消费方会像 workspaces manager 那样挂到现有的宿主帧分发上:命中某个已列出 child 的 `host/session-status` 帧会通过与普通会话 `running` 相同的乐观 mutation 路径就地翻转该 child 的 `running`/`inactive` 活动状态,且不重新拉取 `subagent.list`。parent 与某个已打开分支匹配的 `host/session-added` 帧会触发一次去抖动、单次并发(single-flight)的 `subagent.list` 重新拉取,以纳入新成员及其 label 与描述符。组件局部状态负责下拉菜单可见性、已展开分支 id 与键盘焦点。
|
||||
|
||||
`ui-conversation` 会在标题旁声明并渲染会话作用域的 `conversation.session.header.actions` 列表 slot。现有 `@deepseek-ai/dsh-client-ui-subagent` 插件会在该处注册目录触发器与下拉菜单,并通过对话现有的组合点提供 subagent 专用的只读输入框呈现。扩展该包可以让 Web subagent 功能只有一个 owner;当前的 `@label` 引用 source 仍然只是面向模型的纯文本输入,本提案不会赋予它继续执行语义。
|
||||
|
||||
界面呈现遵循现有样式和无障碍规则:产品文案使用中文、亮色与暗色只使用 token、采用 tree 与 treeitem 语义、ArrowRight/ArrowLeft 控制分支展开、ArrowUp/ArrowDown 进行线性导航、关闭后焦点返回触发器、活动状态同时通过文字和颜色表达、禁用的 diagnostic 行仍然可读。组件只接收派生 props 与注入的回调;绝不接收 `ctx` 或宿主服务。
|
||||
|
||||
## 默认 Web 组合
|
||||
|
||||
`dsh web` 组合会在 JSONL 持久化旁挂载 SQLite 会话查询提供方,其数据库位于已配置的会话持久化根目录下。进程内 spawn 与 fork 委派工具都会选择 `backgroundMode: continuable`;该路由使用继续执行 manager 与 Agent inbox,而非 Task。宿主目录投影会排除 one-shot 条目,远程 ACP 运行则因为不发布本地 child Session 而不进入目录。
|
||||
|
||||
默认 Web 组合会挂载面向模型的 `send_message` 与 `list_agents` 适配器,以保持 coordinator 对等性,但 GUI 不会调用这些工具;它会通过宿主 RPC 适配器调用共享的 `SubagentService`。这些工具面向模型的 schema 与快照将独立于 GUI transcript 进行验证。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**复用普通 `session.history` 与 `session.prompt`。** 不予采纳,因为这两条路径都会直接恢复或驱动 child Agent,绕过继续执行 manager 的授权与 inbox 准入。展示不得触发 Activation,用户输入必须与 parent 输入共用同一项 follow-up 或 cold resume 操作。
|
||||
|
||||
**将适配器放入 `dsh-host-webserver`。** 不予采纳,因为 subagent 列表与继续交互是通道无关的客户端功能。webserver 只承载已校验的 RPC 调用与 SSE 帧,不负责 harness 服务或业务路由。
|
||||
|
||||
**新建另一个 UI 包(package)。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录、导航与用户继续交互的自然功能边界。对话包只负责页头 slot 与通用对话 chrome。
|
||||
|
||||
**用户提交时自动恢复缺失的 parent。** 不予采纳,因为用户继续交互必须通过确切的存活 parent Agent 完成授权。静默激活 parent 还会让 child 页面上的操作意外改变 parent 生命周期。
|
||||
|
||||
**立即公开普通取消操作。** 不予采纳,因为取消 Agent 会绕过继续执行 manager 的所有权与 child-first 清理。正确的取消控件需要当前 Activation 身份,以及能够界定取消一条 inbox 消息、一个轮次还是整个驻留 epoch 的 owner 授权操作,其中也包括 GUI 未启动的 Activation。
|
||||
|
||||
**向持久化目录添加 Activation 结果与时间字段。** 暂缓,因为目录有意只描述持久化 child 身份与粗粒度的存活状态。持久化 Activation 记录属于独立的后端契约,不应根据会话存在状态或最后一次 `turn/end` 推断。
|
||||
|
||||
**构建预先加载的递归树。** 不予采纳,因为 `listChildren()` 只查询直接 child,而且可能扫描每份候选日志。界面通过懒加载的直接 child 查询组合出递归树,既保留每份目录的排序与 diagnostic 语义,又不会在用户看不到的层级中成倍增加工作量。
|
||||
|
||||
**从侧边栏会话树中移除所有 subagent。** 暂缓,因为 `session.list` 公开谱系,却不公开可继续身份;目录又按 parent 寻址,而不是一种低成本的全局分类器。隐藏所有 child 会话还会隐藏普通 fork。页头将成为权威的 subagent 入口;侧边栏要完全去重,仍需等待可扩展的持久化投影。
|
||||
|
||||
**将目录变化作为专用服务端流推送。** 暂缓,转而复用现有的 `host/session-status` 与 `host/session-added` 分发。`subagent.catalog` 增量帧能让成员与 diagnostic 完全实时而无需任何重新拉取,但它是一项新的宿主协议契约,也是在持久化目录之上的实时投影——恰恰是[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)留待规模验证的派生索引。第一版从现有存活帧翻转活动状态,只在成员变化时重新拉取。
|
||||
|
||||
**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为这会重新解释可继续后台工作的含义。独立生命周期、用户所有权与回并语义属于交互式 side session。
|
||||
|
||||
## 测试
|
||||
|
||||
- 默认 Web 组合会创建可继续的 spawn 与 fork child,并挂载持久化枚举及用户 follow-up 所需的会话查询与继续执行接口。
|
||||
- 选中的 parent 会按稳定服务顺序显示完整的直接 child 目录,其中包含健康条目的 label/活动状态与明确禁用的 diagnostic;目录完全为空时,不提供页头操作。展开 child 时会获取其直接目录,以树语义和直接 parent 地址显示下一层,并在分支关闭时递归关闭后代消费方。
|
||||
- 打开持久化 child 后,系统会渲染其事件 transcript 与标题栏,且不发布或恢复 child 与 parent。
|
||||
- parent 存活时,child 会通过 `SubagentService.followup()` 提交带用户来源信息的输入;UI 会收到已接纳的 inbox `messageId`,并通过现有 mux 与 fold 接收由此产生的 child 事件。
|
||||
- 已列出的 `running` child 结算为 `inactive` 时,其活动状态会从实时帧流就地更新,而不重新拉取 `subagent.list`;新创建的直接 child 会在一次去抖动的重新拉取后出现。
|
||||
- parent 缺失时,child 仍然可读并拒绝输入,且不会自动恢复 parent。任何 child 历史、提示词或停止操作都不会调用普通 agent API。
|
||||
- 刷新和重新连接会通过 subagent 历史路径重建已寻址的 child,不重复事件,也不会丢失 cold resume 发布前后产生的事件。
|
||||
- 宿主协议测试固定 schema、id 回显、直接 parent 校验、非激活式历史、存活 parent 强制要求、错误映射以及 inbox 消息确认。客户端对象测试固定目录/地址状态与传输选择;jsdom 测试固定页头树、懒加载式嵌套展开、diagnostic、启用/只读输入框状态、键盘行为与草稿恢复。
|
||||
- 一项无密钥的组装 Web 快照展示已结算的可继续 child 与带描述符的已持久化 grandchild、在不物化 Activation 的情况下逐层展开目录、从持久化存储打开,以及让 cold-resume Activation 的 inbox 接受一次用户后续消息。
|
||||
|
||||
## 后果
|
||||
|
||||
- 该功能建立在继续执行与持久化目录契约之上;在本提案可以交付前,其堆叠实现的变更可能要求宿主适配器与 fixture(测试前置数据)一同调整。
|
||||
- `listChildren()` 可能重新扫描持久化存储与 child 日志(按持久化目录为 O(D×C + ΣLᵢ))。因此活动状态变化通过 `host/session-status` 实时应用而不重新拉取;只有成员变化才触发一次去抖动、单次并发的 `subagent.list` 重新加载,所以该扫描不会在每次渲染或每个状态帧上运行。
|
||||
- parent 可用性与 child 活动状态都是进程局部快照。列出之后,发布、Activation dispose、其他发送方或其他进程都可能抢先改变状态;明确的提示词失败属于正常行为,不是违反不变量。
|
||||
- child Activation 可能在历史获取与 mux 订阅之间发布。现有序号归并必须针对这条从冷态转为存活的 subagent 专用打开路径得到验证。
|
||||
- 将默认 Web 委派工具切换为可继续后台模式,会改变 `run_in_background` 面向模型的确认消息与持久性要求;快照覆盖必须与组合变更一同落地。
|
||||
- 暂时在侧边栏中保留 subagent 会产生两条导航路径。安全移除重复入口需要后续的持久化分类或全局投影,不能只在呈现层过滤。
|
||||
- 该功能没有正确的取消按钮、持久化结果、Activation 耗时、删除、目录分页或可独立交互的离线 child。UI 不得暗示这些功能已经存在;其中的相对时间仅表示会话摘要给出的最近活动提示。
|
||||
@@ -121,6 +121,11 @@ export interface LaunchOptions {
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
*/
|
||||
replayChildFixtures?: string[]
|
||||
/**
|
||||
* Optional replay.override.json sidecar (whole-script replacement or
|
||||
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
|
||||
@@ -328,6 +333,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- tree "子代理会话":
|
||||
- treeitem "event-sourcing researcher 已完成" [expanded] [level=1]:
|
||||
- button "收起 event-sourcing researcher 的下级子代理":
|
||||
- img
|
||||
- text: event-sourcing researcher 已完成
|
||||
- group:
|
||||
- treeitem "example editor 已完成" [level=2]:
|
||||
- button "展开 example editor 的下级子代理":
|
||||
- img
|
||||
- text: example editor 已完成
|
||||
@@ -0,0 +1,31 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- text: · 2 turns
|
||||
- button "1 个子代理":
|
||||
- text: 1 个子代理
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Explain event sourcing in one sentence.
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- text: Now give the same explanation to a human reader.
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- text: cache hit 99% · 15,738 tokens · 2 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "Send message" [disabled]
|
||||
219
apps/web/tests/subagent-conversation.e2e.ts
Normal file
219
apps/web/tests/subagent-conversation.e2e.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
|
||||
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
|
||||
const [header, ...eventLines] = source.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
const childHeader = header
|
||||
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
|
||||
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
|
||||
const continued = eventLines.map(line => line
|
||||
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
|
||||
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
|
||||
.replaceAll('"turn":1', '"turn":2'))
|
||||
return [childHeader, ...eventLines, ...continued, ''].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: persisted subagent conversation and human continuation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let childId: SessionId
|
||||
let grandchildId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||||
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
|
||||
const childFixturePath = join(sidecarRoot, 'child.jsonl')
|
||||
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: BASE_FIXTURE,
|
||||
replayChildFixtures: [childFixturePath],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
|
||||
const parent = scaffold.ctx.agents.roots()[0]
|
||||
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill(PARENT_PROMPT)
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||||
parent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.get(childId),
|
||||
{ timeout: 30_000 },
|
||||
).toBeUndefined()
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: grandchildId,
|
||||
createdAt: authoredAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: childId,
|
||||
delegationDepth: 2,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(grandchildId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: authoredAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: authoredAt + 2,
|
||||
data: snapshotSubagentDescriptor({ provider: 'spawn', label: NESTED_LABEL }),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: authoredAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||||
{ kind: 'child', id: childId, label: LABEL, activity: 'inactive' },
|
||||
])
|
||||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||||
{ kind: 'child', id: grandchildId, label: NESTED_LABEL, activity: 'inactive' },
|
||||
])
|
||||
await page.getByRole('button', { name: '1 个子代理' }).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
|
||||
})
|
||||
|
||||
it('expands a persisted grandchild progressively without activating either level', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
|
||||
await page.getByRole('button', { name: '1 个子代理' }).click()
|
||||
await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="子代理会话"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tree', { name: '子代理会话' }).press('Escape')
|
||||
})
|
||||
|
||||
it('opens the completed child from persistence without activating it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||||
await page.getByRole('button', { name: '1 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(
|
||||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(1)
|
||||
if (scaffold.ctx.agents.get(childId) !== undefined) {
|
||||
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
|
||||
}
|
||||
await page.getByRole('heading', { name: LABEL }).waitFor()
|
||||
})
|
||||
|
||||
it('continues through a cold-resumed Activation and receives the child mux events', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('subagent follow-up did not reach turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await ended
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,8 @@
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts"
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -36,12 +36,29 @@ export interface ISessions {
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns the retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog menu is consuming live membership updates.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @param open - current menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @returns completion of the current or newly started refresh.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void>
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The double reports the wire schema's own search bound, like the production
|
||||
// service — a transport-varying limit would be a fiction no client can see.
|
||||
@@ -171,8 +172,12 @@ export class TestSessions implements ISessions {
|
||||
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
|
||||
/** Calls observed on the service-level face, newest last. */
|
||||
readonly calls: {
|
||||
method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents'
|
||||
| 'clear' | 'search' | 'fork'
|
||||
args: unknown[]
|
||||
}[] = []
|
||||
|
||||
/** The wire schema's `session.search` result bound (production parity). */
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
@@ -393,18 +398,46 @@ export class TestSessions implements ISessions {
|
||||
open(id: SessionId): void {
|
||||
this.calls.push({ method: 'open', args: [id] })
|
||||
this.require(id)
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
this.list.update((draft) => {
|
||||
draft.current = id
|
||||
draft.currentAddress = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Test fixtures do not synthesize catalog addresses. */
|
||||
subagentAddress(_id: SessionId): undefined {
|
||||
return undefined
|
||||
/** Open an existing fixture through its catalog address. */
|
||||
openSubagent(address: SubagentAddress): void {
|
||||
this.calls.push({ method: 'openSubagent', args: [address] })
|
||||
this.require(address.childSessionId)
|
||||
this.list.update((draft) => {
|
||||
draft.current = address.childSessionId
|
||||
draft.currentAddress = address
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the current fixture's retained catalog address. */
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||||
const address = this.list.getSnapshot().currentAddress
|
||||
return address?.childSessionId === id ? address : undefined
|
||||
}
|
||||
|
||||
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
this.calls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
|
||||
}
|
||||
|
||||
/** Record a catalog refresh; fixture callers drive snapshots explicitly. */
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
this.calls.push({ method: 'refreshSubagents', args: [parentSessionId] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
this.list.update((draft) => {
|
||||
draft.current = undefined
|
||||
draft.currentAddress = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: 845e12d760326b97a7e1fbffbd1655a73c5b5174
|
||||
README.zh.md: eab8d6663848b1130e41e2d581f13ad93a50b545
|
||||
README.md: fef922ab42e71813faaa826cc2580bdec72baa74
|
||||
README.zh.md: 4c1e7670bbfceaed73328108d3b5a3e646ee86bf
|
||||
|
||||
@@ -12,6 +12,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes `subagentReadOnly`; ui-subagent claims that state to explain the unavailable-parent condition, while the ordinary InputBar hides Stop for every addressed continuable subagent conversation because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含 `subagentReadOnly`;ui-subagent 会接管该状态并说明 parent 不可用,而普通 InputBar 会在所有已寻址的可继续 subagent 对话中隐藏 Stop,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
|
||||
|
||||
通用工具行把内置的 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 变体的可展开源码渲染。
|
||||
|
||||
@@ -165,7 +165,10 @@ export function apply(ctx: Context): void {
|
||||
// the resident parent keeps Hero and composer layout identity stable.
|
||||
slots.register({
|
||||
name: 'conversation.session',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
|
||||
views: {
|
||||
|
||||
@@ -17,6 +17,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* remounted when the current session id changes.
|
||||
*/
|
||||
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
|
||||
/** Session-header actions contributed by feature plugins. */
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
@@ -135,6 +137,9 @@ export interface ConversationSessionOwnerProps {
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/** Header actions derive their state from the standard session/global kit. */
|
||||
export interface ConversationHeaderActionOwnerProps {}
|
||||
|
||||
/**
|
||||
* The input-region slot currency (plan §1.4): dock/left/right entries read
|
||||
* the conversation snapshot and the live input state as owner props (both
|
||||
@@ -329,6 +334,8 @@ export type ComposerBarProps =
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
interactions: readonly PendingInteraction[]
|
||||
/** A catalog-addressed child whose exact parent Agent is unavailable. */
|
||||
subagentReadOnly: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,7 +357,7 @@ export type ConversationSlotProps =
|
||||
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
|
||||
export type ConversationSessionSlotProps =
|
||||
PropsRuntime<'conversation.session'>
|
||||
& PropsRenderSlots<'conversation.view'>
|
||||
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ConversationRoot({
|
||||
const openState = useSession(s => s.openState)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const pending = useSession(s => s.pending) ?? []
|
||||
const subagentReadOnly = useSession(s => s?.subagent?.parentAvailable === false) ?? false
|
||||
const session = useSession(s => s)
|
||||
const inputState = useInput(s => s)
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
@@ -153,7 +154,7 @@ export function ConversationRoot({
|
||||
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ interactions: pending, subagentReadOnly },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ export function ConversationSession({
|
||||
<>
|
||||
<div className={css.titleRow}>
|
||||
<h1 className={css.sessionTitle}>{title}</h1>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
</div>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
|
||||
@@ -44,6 +44,7 @@ export function InputBar({
|
||||
const commandMenuOpen = useMenuLauncher(source => source === 'command')
|
||||
const promptError = useSession(s => s.promptError) ?? null
|
||||
const running = useSession(s => s.running) ?? false
|
||||
const subagent = useSession(s => s.subagent) ?? null
|
||||
const removed = useSession(s => s.removed) ?? false
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
@@ -283,10 +284,11 @@ export function InputBar({
|
||||
if (el !== null) toggleCommandMenu?.(selectionOf(el))
|
||||
}
|
||||
|
||||
const primaryLabel = running ? t('input.stop') : t('input.send')
|
||||
const ordinary = subagent === null
|
||||
const primaryLabel = running && ordinary ? t('input.stop') : t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
|
||||
if (running) {
|
||||
if (running && ordinary) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function snapshotWith(
|
||||
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
|
||||
@@ -34,7 +34,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -113,7 +113,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -29,7 +29,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
})
|
||||
const props: InputBarProps = {
|
||||
sessionId: SID,
|
||||
@@ -37,6 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -115,7 +115,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
})
|
||||
const barProps: InputBarProps = {
|
||||
sessionId,
|
||||
@@ -123,6 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -30,7 +30,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function mount(
|
||||
ids: listed ? [root, SID] : [root],
|
||||
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
|
||||
@@ -26,6 +26,7 @@ const seatOver = (dict: Record<string, string>, common: Record<string, string>):
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kit = {
|
||||
subagentReadOnly: false,
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
|
||||
@@ -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: 7a70add139eae7bc507469b4fe7170359efdec31
|
||||
README.zh.md: 4ff79780fd33a47a0a45695ab9feda15cd1763cd
|
||||
README.md: 06cfc368577a41b405336025e75e61998b2051ae
|
||||
README.zh.md: 64b096ad4804694a9b0d7d3c8c26a8f3f867bf23
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, the unavailable-parent replacement to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
|
||||
|
||||
A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Each healthy row combines its durable label, `running`/`inactive` activity (rendered as `正在处理`/`已完成`), optional log-backed title, and session-summary activity time; corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch 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}` 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 `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
An addressed child with no exact live parent elects the read-only composer entry and explains the recovery path. A child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; 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).
|
||||
|
||||
The `@` source remains deliberately separate and inert. Candidates are zero-RPC running children from `ctx.sessions.list`; picking one inserts literal `@label ` text, and the codec projects `@label`. It has no command-adjudication hooks and does not resolve labels into continuation addresses.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,18 +16,18 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided.
|
||||
Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, persisted transcript viewing, and human continuation UI add no prompt section; continuation content becomes a normal user-role event through the host subagent adapter.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser).
|
||||
Conditional and append-only: the literal `@label` or a human follow-up adds tokens only to its new user message. Catalog and transcript operations add zero model tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
|
||||
Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger.
|
||||
- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents).
|
||||
- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids.
|
||||
- **The catalog has coarse liveness only** — it cannot show durable outcome, elapsed time, exact Activation state, or a correct cancel button.
|
||||
- **The sidebar still contains child sessions** — complete de-duplication needs a scalable durable classifier that does not hide ordinary forks.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace`/`matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献 parent 不可用时的替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
|
||||
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering(中途引导)、恢复已 dispose(资源释放)的子会话)是未来的业务工作。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。每个健康行都组合其持久化 label、`running`/`inactive` 活动状态(分别呈现为「正在处理」/「已完成」)、由日志支撑的可选 title 与会话摘要中的活动时间;损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
|
||||
`/client` 的导出内容只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。
|
||||
已寻址 child 没有确切的存活 parent 时,会选中只读编辑器配置项并说明恢复路径。parent 存活时,child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
|
||||
|
||||
`@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 child;pick 会插入字面文本 `@label `,codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,18 +16,18 @@ subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
|
||||
只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child、查看持久化 transcript 与用户继续交互 UI 都不会添加提示词 section;继续交互内容会经宿主 subagent 适配器成为普通 user-role 事件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且极小:只有 pick(或手动键入相同文本)会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token(候选永不离开浏览器)。
|
||||
有条件且仅追加:字面 `@label` 或用户后续消息只会向对应的新用户消息增加 token。目录与 transcript 操作增加零模型 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
|
||||
仅追加。本包绝不改写更早的请求 token。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`@` 消费语义尚未构建**:引用只是不具消费语义的纯文本;将其接入对指名子会话进行 steering/发送消息的机制(以及是否允许恢复已 dispose 的子会话),仍有待台账中的专门设计决策。
|
||||
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现;roster 只含 scope 所指会话的直接子会话,不含孙辈,也不含跨会话 agent(智能体)。
|
||||
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用仍是不具消费语义的纯文本时尚可接受;消费功能必须绑定到会话 id。
|
||||
- **目录只有粗粒度存活状态**:它不能显示持久化结果、耗时、确切的 Activation 状态或正确的取消按钮。
|
||||
- **侧边栏仍包含 child Session**:完全去重需要可扩展的持久化分类器,且不得误隐藏普通 fork。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-subagent",
|
||||
"description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references",
|
||||
"description": "Subagent conversation catalog, continuation routing UI, and '@' reference source",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -25,6 +25,8 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,8 +36,13 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@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",
|
||||
@@ -43,9 +50,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 336px;
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(560px, calc(100vh - 140px));
|
||||
padding: 6px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.row > :global([data-state]) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.disclosure,
|
||||
.disclosureSpace {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.disclosure {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.disclosure:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.disclosureOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label,
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.time {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.children {
|
||||
position: relative;
|
||||
margin-left: 15px;
|
||||
padding-left: 11px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.children > .node > .row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: -12px;
|
||||
width: 9px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.notice,
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
363
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
Normal file
363
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
SessionId, SessionListState, 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 css from './SubagentCatalogAction.module.css'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
type Catalogs = SessionListState['subagentsByParent']
|
||||
|
||||
/** Business actions supplied by the slot registration. */
|
||||
export interface SubagentCatalogInjected {
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
setCatalogOpen(parentSessionId: SessionId, open: boolean): void
|
||||
}
|
||||
|
||||
/** Full props for the session-header catalog action. */
|
||||
export type SubagentCatalogActionProps =
|
||||
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
|
||||
|
||||
interface CatalogRowsProps {
|
||||
parentSessionId: SessionId
|
||||
catalog: SubagentCatalogSnapshot
|
||||
catalogs: Catalogs
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>
|
||||
expanded: ReadonlySet<SessionId>
|
||||
level: number
|
||||
now: number
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
toggleBranch(childSessionId: SessionId): void
|
||||
closeCatalog(): void
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return '会话记录损坏'
|
||||
case 'unsupported': return '不是可继续的子代理'
|
||||
case 'unavailable': return '会话记录暂不可用'
|
||||
}
|
||||
}
|
||||
|
||||
function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
||||
return root === null
|
||||
? []
|
||||
: 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))}年`
|
||||
}
|
||||
|
||||
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
||||
openChild, refresh, toggleBranch, closeCatalog,
|
||||
}: CatalogRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{catalog.state === 'loading' && catalog.entries.length === 0 && (
|
||||
<div className={css.notice}>正在加载子代理…</div>
|
||||
)}
|
||||
{catalog.state === 'error' && (
|
||||
<div className={css.error}>
|
||||
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.refresh}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{catalog.entries.map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
const reason = diagnosticReason(entry)
|
||||
return (
|
||||
<div key={entry.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label={`${entry.id} ${reason}`}
|
||||
className={`${css.row} ${css.disabled}`}
|
||||
title={reason}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
<StateDot state="error" />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.id}</span>
|
||||
<span className={css.summary}>{reason}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const isExpanded = expanded.has(entry.id)
|
||||
const knownLeaf = childCatalog?.state === 'ready' && childCatalog.entries.length === 0
|
||||
const summary = summaries[entry.id]
|
||||
const secondary = summary?.title ?? (entry.activity === 'running' ? '正在处理' : '已完成')
|
||||
const time = relativeTime(summary?.updatedAt, now)
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id })
|
||||
closeCatalog()
|
||||
}
|
||||
const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
} else if (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
} else if (event.key === 'ArrowLeft' && isExpanded) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
}
|
||||
}
|
||||
const toggle = (event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[entry.label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
{knownLeaf
|
||||
? <span className={css.disclosureSpace} />
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'} ${entry.label} 的下级子代理`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
)}
|
||||
<StateDot state={entry.activity === 'running' ? 'ongoing' : 'done'} />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
</div>
|
||||
{isExpanded && childCatalog !== undefined && !knownLeaf && (
|
||||
<div role="group" className={css.children}>
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={level + 1}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={closeCatalog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's direct catalog and lazily expanded descendants.
|
||||
* @param props - session standard props plus catalog navigation actions.
|
||||
* @returns The action only after a non-empty catalog arrives.
|
||||
*/
|
||||
export function SubagentCatalogAction({
|
||||
sessionId, useSessions, openChild, refresh, setCatalogOpen,
|
||||
}: SubagentCatalogActionProps) {
|
||||
const catalogs = useSessions(state => state.subagentsByParent)
|
||||
const summaries = useSessions(state => state.byId)
|
||||
const catalog = catalogs[sessionId]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const observedCatalogs = useRef(new Set<SessionId>())
|
||||
const setCatalogOpenRef = useRef(setCatalogOpen)
|
||||
setCatalogOpenRef.current = setCatalogOpen
|
||||
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
|
||||
|
||||
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
|
||||
if (next) observedCatalogs.current.add(parentSessionId)
|
||||
else observedCatalogs.current.delete(parentSessionId)
|
||||
setCatalogOpen(parentSessionId, next)
|
||||
}
|
||||
|
||||
const closeAllCatalogs = (): void => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpen(parentSessionId, false)
|
||||
}
|
||||
observedCatalogs.current.clear()
|
||||
setExpanded(new Set())
|
||||
}
|
||||
|
||||
const changeOpen = (next: boolean, restoreFocus = false): void => {
|
||||
setOpen(next)
|
||||
if (next) observeCatalog(sessionId, true)
|
||||
else closeAllCatalogs()
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const closeBranch = (root: SessionId): void => {
|
||||
const closing = new Set<SessionId>()
|
||||
const visit = (parentSessionId: SessionId): void => {
|
||||
if (closing.has(parentSessionId) || !expanded.has(parentSessionId)) return
|
||||
closing.add(parentSessionId)
|
||||
const branch = catalogs[parentSessionId]
|
||||
for (const entry of branch?.entries ?? []) {
|
||||
if (entry.kind === 'child') visit(entry.id)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
for (const parentSessionId of closing) observeCatalog(parentSessionId, false)
|
||||
setExpanded(current => new Set([...current].filter(id => !closing.has(id))))
|
||||
}
|
||||
|
||||
const toggleBranch = (childSessionId: SessionId): void => {
|
||||
if (expanded.has(childSessionId)) {
|
||||
closeBranch(childSessionId)
|
||||
return
|
||||
}
|
||||
setExpanded(current => new Set(current).add(childSessionId))
|
||||
observeCatalog(childSessionId, true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: PointerEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
|
||||
changeOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpenRef.current(parentSessionId, false)
|
||||
}
|
||||
observedCatalogs.current.clear()
|
||||
}, [])
|
||||
|
||||
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
closeAllCatalogs()
|
||||
}, [visible, open])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const focusAt = (index: number): void => {
|
||||
const items = treeItems(rootRef.current)
|
||||
if (items.length === 0) return
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
const navigate = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = treeItems(rootRef.current)
|
||||
const index = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
changeOpen(false, true)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
focusAt(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
focusAt(items.length - 1)
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAt(index + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAt(index < 0 ? items.length - 1 : index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root} ref={rootRef} onKeyDown={navigate}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-haspopup="tree"
|
||||
aria-expanded={open}
|
||||
onClick={() => { changeOpen(!open) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowDown') return
|
||||
event.preventDefault()
|
||||
if (!open) changeOpen(true)
|
||||
queueMicrotask(() => { focusAt(0) })
|
||||
}}
|
||||
>
|
||||
<span>{healthy.length} 个子代理</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open && catalog !== undefined && (
|
||||
<div className={css.menu} role="tree" aria-label="子代理会话">
|
||||
<CatalogRows
|
||||
parentSessionId={sessionId}
|
||||
catalog={catalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
now={Date.now()}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={() => { changeOpen(false) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0 24px 20px;
|
||||
min-height: 54px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.frame strong {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 510;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './SubagentReadOnlyComposer.module.css'
|
||||
|
||||
/** Full chain props after the read-only subagent selector accepts the owner currency. */
|
||||
export type SubagentReadOnlyComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: ComposerChainProps }
|
||||
|
||||
/**
|
||||
* Explain why the normal composer is unavailable for a parentless child.
|
||||
* @returns A read-only composer replacement.
|
||||
*/
|
||||
export function SubagentReadOnlyComposer() {
|
||||
return (
|
||||
<div className={css.frame} role="status">
|
||||
<strong>此子代理暂时只读</strong>
|
||||
<span>父会话当前不在线,重新打开父会话后即可继续发送消息。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,18 +9,33 @@
|
||||
* business work (design ledger). No adjudication hooks: subagent
|
||||
* references never enter command adjudication.
|
||||
*/
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ClientContext, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services: the slash registry + the session list face the source closes over. */
|
||||
export const inject = ['slash', 'sessions']
|
||||
export type {
|
||||
SubagentCatalogActionProps, SubagentCatalogInjected,
|
||||
} from './SubagentCatalogAction.tsx'
|
||||
export type { SubagentReadOnlyComposerProps } from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services for references, conversation slots, and session navigation. */
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots']
|
||||
|
||||
/** Claim the composer only when an addressed child has no live continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): ComposerChainProps | null {
|
||||
return owner.subagentReadOnly ? owner : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '@' subagent source over the root session list.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.get('sessions') as SessionsService
|
||||
const sessions = ctx.sessions
|
||||
// Child labels live on the session list (parentId lineage + displayTitle),
|
||||
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
|
||||
const childLabels = (session: ClientSessionContext, query: string): string[] => {
|
||||
@@ -59,4 +74,33 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
|
||||
|
||||
const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
|
||||
openChild(address: SubagentAddress) {
|
||||
sessions.openSubagent(address)
|
||||
},
|
||||
refresh(parentSessionId: SessionId) {
|
||||
void sessions.refreshSubagents(parentSessionId)
|
||||
},
|
||||
setCatalogOpen(parentSessionId: SessionId, open: boolean) {
|
||||
sessions.setSubagentCatalogOpen(parentSessionId, open)
|
||||
},
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'subagent-catalog',
|
||||
order: 10,
|
||||
inject: catalogActions,
|
||||
}, SubagentCatalogAction),
|
||||
'ui-subagent: lazy descendant catalog action',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.composer',
|
||||
priority: 10,
|
||||
select: selectReadOnlySubagent,
|
||||
}, SubagentReadOnlyComposer),
|
||||
'ui-subagent: unavailable-parent composer',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ function sessionsWith(sessions: SessionSummary[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function provideSlotFaces(ctx: Context): void {
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('slots', { register: () => () => {} })
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
async function fullBench(sessions: SessionSummary[]) {
|
||||
const ctx = new Context()
|
||||
@@ -50,6 +55,7 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', face)
|
||||
provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { source: captured!, face }
|
||||
}
|
||||
@@ -76,13 +82,14 @@ const req = (query: string) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots'])
|
||||
})
|
||||
|
||||
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
provideSlotFaces(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
|
||||
188
packages/client/ui-subagent/tests/conversation-ui.spec.tsx
Normal file
188
packages/client/ui-subagent/tests/conversation-ui.spec.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
SessionId, SessionListState, SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
SubagentCatalogAction, type SubagentCatalogActionProps,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const PARENT = 'parent' as SessionId
|
||||
const CHILD = 'child' as SessionId
|
||||
const GRANDCHILD = 'grandchild' as SessionId
|
||||
|
||||
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
|
||||
return {
|
||||
entries: [
|
||||
{ kind: 'child', id: CHILD, label: 'worker', activity: 'running' },
|
||||
{ kind: 'child', id: 'child-2' as SessionId, label: 'reviewer', activity: 'inactive' },
|
||||
{ kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function props(
|
||||
value: SubagentCatalogSnapshot | undefined,
|
||||
nested: Readonly<Record<SessionId, SubagentCatalogSnapshot>> = {},
|
||||
) {
|
||||
const state = {
|
||||
ids: [CHILD],
|
||||
byId: {
|
||||
[CHILD]: {
|
||||
id: CHILD,
|
||||
title: '正在扫描项目文件',
|
||||
displayTitle: 'worker',
|
||||
running: true,
|
||||
blank: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
current: PARENT, phase: 'ready',
|
||||
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
return {
|
||||
sessionId: PARENT,
|
||||
useSessions: (<T,>(select: (snapshot: SessionListState) => T) => select(state)),
|
||||
openChild: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
setCatalogOpen: vi.fn(),
|
||||
} as unknown as SubagentCatalogActionProps
|
||||
}
|
||||
|
||||
describe('SubagentCatalogAction', () => {
|
||||
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
|
||||
const input = props(catalog())
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
|
||||
expect(screen.getAllByRole('treeitem')).toHaveLength(3)
|
||||
expect(screen.getByText('正在扫描项目文件')).toBeTruthy()
|
||||
expect(screen.getByText('已完成')).toBeTruthy()
|
||||
const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ })
|
||||
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
|
||||
expect(input.openChild).toHaveBeenCalledWith({
|
||||
parentSessionId: PARENT, childSessionId: CHILD,
|
||||
})
|
||||
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
|
||||
})
|
||||
|
||||
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
|
||||
const input = props(catalog())
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'End' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'Escape' })
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
const childCatalog = catalog({
|
||||
entries: [
|
||||
{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' },
|
||||
],
|
||||
})
|
||||
const grandchildCatalog = catalog({ entries: [] })
|
||||
const input = props(catalog(), {
|
||||
[CHILD]: childCatalog,
|
||||
[GRANDCHILD]: grandchildCatalog,
|
||||
})
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
|
||||
const nested = screen.getByRole('treeitem', { name: /indexer/ })
|
||||
expect(nested.getAttribute('aria-level')).toBe('2')
|
||||
|
||||
fireEvent.click(nested)
|
||||
expect(input.openChild).toHaveBeenCalledWith({
|
||||
parentSessionId: CHILD, childSessionId: GRANDCHILD,
|
||||
})
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
})
|
||||
|
||||
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
|
||||
const input = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'running' }],
|
||||
}),
|
||||
})
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
const worker = screen.getByRole('treeitem', { name: /worker/ })
|
||||
fireEvent.keyDown(worker, { key: 'ArrowRight' })
|
||||
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
|
||||
fireEvent.keyDown(worker, { key: 'ArrowLeft' })
|
||||
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
})
|
||||
|
||||
it('hides an arrived empty catalog and exposes retry for a failed one', () => {
|
||||
const empty = props(catalog({ entries: [] }))
|
||||
const view = render(<SubagentCatalogAction {...empty} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
view.unmount()
|
||||
|
||||
const failed = props(catalog({
|
||||
entries: [],
|
||||
state: 'error',
|
||||
error: { code: 'internal', message: 'index down', details: {} },
|
||||
}))
|
||||
render(<SubagentCatalogAction {...failed} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
|
||||
expect(screen.getByText('index down')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /重试/ }))
|
||||
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
|
||||
})
|
||||
|
||||
it('closes every observed catalog when the root becomes empty', () => {
|
||||
const populated = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' }],
|
||||
}),
|
||||
})
|
||||
const view = render(<SubagentCatalogAction {...populated} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
|
||||
|
||||
const empty = props(catalog({ entries: [] }))
|
||||
view.rerender(<SubagentCatalogAction {...empty} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(empty.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
|
||||
expect(empty.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentReadOnlyComposer', () => {
|
||||
it('explains the exact missing-parent recovery path', () => {
|
||||
render(<SubagentReadOnlyComposer />)
|
||||
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,12 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -1868,10 +1868,20 @@ importers:
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/client/ui-subagent:
|
||||
dependencies:
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slash':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slash
|
||||
@@ -1881,6 +1891,9 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
Reference in New Issue
Block a user