feat(web): add nested subagent conversations

This commit is contained in:
Dudu-0223
2026-07-27 18:34:58 +08:00
committed by Tianyi Cui
parent a27492507d
commit 16ffd63115
42 changed files with 1526 additions and 55 deletions

View File

@@ -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
/**

View File

@@ -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
})
}
/**

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 845e12d760326b97a7e1fbffbd1655a73c5b5174
README.zh.md: eab8d6663848b1130e41e2d581f13ad93a50b545
README.md: fef922ab42e71813faaa826cc2580bdec72baa74
README.zh.md: 4c1e7670bbfceaed73328108d3b5a3e646ee86bf

View File

@@ -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.

View File

@@ -10,6 +10,8 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession 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 变体的可展开源码渲染。

View File

@@ -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: {

View File

@@ -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

View File

@@ -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;

View File

@@ -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 },
)

View File

@@ -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">

View File

@@ -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
}

View File

@@ -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() }

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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>,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: 7a70add139eae7bc507469b4fe7170359efdec31
README.zh.md: 4ff79780fd33a47a0a45695ab9feda15cd1763cd
README.md: 06cfc368577a41b405336025e75e61998b2051ae
README.zh.md: 64b096ad4804694a9b0d7d3c8c26a8f3f867bf23

View File

@@ -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.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running``displayTitle` 包含 querypick 一个候选会把字面文本 `@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()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、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 得到的运行中 childpick 会插入字面文本 `@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 会有歧义,因此它们刻意不获得继续执行语义

View File

@@ -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": [

View File

@@ -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);
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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',
)
}

View File

@@ -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

View 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('父会话当前不在线')
})
})

View File

@@ -14,6 +14,12 @@
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},