Merge master into subagent usage branch

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
#	apps/web/tests/snapshots/subagent-conversation/tree.expected.md
#	apps/web/tests/subagent-conversation.e2e.ts
#	packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
This commit is contained in:
kingwl
2026-08-02 23:37:17 +08:00
102 changed files with 1586 additions and 291 deletions

View File

@@ -59,6 +59,8 @@ interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
/** Removal-time invalidation replayed over the response this request predates. */
parentAvailableOverride: false | undefined
}
type SessionListMutation =
@@ -101,6 +103,8 @@ export class SessionManager {
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
@@ -301,22 +305,26 @@ export class SessionManager {
try {
const { result } = await this.api.subagents.list({ parentSessionId })
if (result.ok) {
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? result.value.parentAvailable
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
parentAvailable,
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable)
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
})
@@ -327,16 +335,26 @@ export class SessionManager {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
// Re-arm the trailing pull before the dirty notify: the response the
// caller observed predates the stale-marking change, so the follow-up
// refresh is the only carrier of that change.
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
this.catalogInflight.set(parentSessionId, {
promise: operation,
expandableRows,
activityRows,
parentAvailableOverride: undefined,
})
return operation
}
@@ -673,6 +691,29 @@ export class SessionManager {
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
// the writable editor this invalidation just closed. Replay false over
// that response and queue one trailing refresh so the post-removal
// host truth converges.
const inflightCatalog = this.catalogInflight.get(frame.sessionId)
if (inflightCatalog !== undefined) {
inflightCatalog.parentAvailableOverride = false
this.catalogStale.add(frame.sessionId)
}
// The removed session can no longer be the delivery owner of its
// catalog: invalidate availability immediately. Removal schedules no
// catalog refresh, and without this an addressed child keeps a
// writable editor against a dead continuation owner until an
// unrelated refresh (or forever, for a closed menu).
const ownedCatalog = this.catalogs.get(frame.sessionId)
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false })
}
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== frame.sessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(false)
}
return
}
case 'host/session-status': {
@@ -724,11 +765,18 @@ export class SessionManager {
for (const session of this.sessions.values()) void session.resync()
}
/** Debounce membership refetches while one parent catalog is open. */
/** Debounce membership refetches while one parent catalog is selected or open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
// The in-flight response predates the membership frame that scheduled
// this callback. Queue one post-settlement pull instead of treating an
// ordinary overlapping read as evidence that catalog membership changed.
if (this.catalogInflight.has(parentSessionId)) {
this.catalogStale.add(parentSessionId)
return
}
void this.refreshSubagents(parentSessionId)
}, 50)
this.catalogDebounce.set(parentSessionId, timer)

View File

@@ -529,6 +529,149 @@ describe('subagent catalogs', () => {
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(api.callsOf('subagent.list')).toHaveLength(1)
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, root)
const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
// which was requested before the new child existed. The stale mark must
// queue one trailing pull carrying the change.
manager.handleHostEnvelope({
rpcId: 'child-added' as never,
payload: {
type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
api.onSubagentList = () => second.promise
first.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
// The trailing pull is already in flight (kicked synchronously in finally).
second.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await second.promise
expect(api.callsOf('subagent.list')).toHaveLength(2)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, label: 'older' },
{ kind: 'child', id: S2, label: 'new child' },
])
} finally {
vi.useRealTimers()
}
})
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const child = () => ({
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
activity: 'inactive' as const, hasChildren: false,
})
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => mid.promise
const midRefresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'parent-removed-mid-pull' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => trailing.promise
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await midRefresh
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
await vi.waitFor(() => {
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
state: 'error',
parentAvailable: false,
})
})
const rootCalls = api.callsOf('subagent.list')
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
expect(rootCalls).toHaveLength(3)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
it('invalidates catalog availability when the owning parent is removed', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleHostEnvelope({
rpcId: 'parent-removed' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
})
describe('remaining branches', () => {

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: ea48725b02ad0c440984af4aadfec17d0e63791d
README.zh.md: 654901caca68762e313dda456c1e0df23faf734a
README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439
README.zh.md: 268e05a806db1468ba689608c178646af132fe25

View File

@@ -16,6 +16,8 @@ The session header declares and renders the session-scoped `'conversation.sessio
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)).
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).

View File

@@ -14,6 +14,8 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。

View File

@@ -39,6 +39,13 @@ function firstLine(text: string): string {
return nl === -1 ? text : text.slice(0, nl)
}
/** Latest non-blank reasoning line while the block is still streaming. */
function latestLine(text: string): string {
const visible = text.trimEnd()
const nl = visible.lastIndexOf('\n')
return nl === -1 ? visible : visible.slice(nl + 1)
}
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
@@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
summary={running ? latestLine(text) : firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
/>

View File

@@ -84,6 +84,11 @@
color: var(--dsw-alias-label-tertiary);
}
/* Live reasoning follows its one-line summary to the inline end. */
.summary[data-follow-end] {
text-overflow: clip;
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;

View File

@@ -5,7 +5,8 @@
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or a card material (terminal, diff,
// read, search, web) is expandable; the summary stays inline while open,
// except Think, whose body opens with the same first line and would repeat it.
// except Think, where the running collapsed row follows the latest line at its
// scroll end and the summary yields while open to avoid repeating the body.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a card
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
@@ -19,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -152,6 +153,7 @@ export function ToolRow({
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const summaryRef = useRef<HTMLSpanElement>(null)
const terminalBody = terminal ?? null
const diffBody = diff ?? null
const readBody = read ?? null
@@ -173,6 +175,15 @@ export function ToolRow({
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -188,9 +199,8 @@ export function ToolRow({
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// plain indented text (no IN/OUT card) and the inline summary yields to avoid
// repeating the body.
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
@@ -227,7 +237,11 @@ export function ToolRow({
{summaryText}
</button>
) : (
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
<span
ref={isThink ? summaryRef : undefined}
className={clsx(css.summary, failureLine !== null && css.errorSummary)}
data-follow-end={followSummaryEnd || undefined}
>
{summaryText}
</span>
)}

View File

@@ -320,6 +320,42 @@ describe('ToolRow', () => {
})
describe('ThinkRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives",
@@ -40,6 +41,7 @@
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@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",
@@ -51,7 +53,9 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -8,7 +8,8 @@ import type {
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-subagent/client'
import type {} from '@deepseek-ai/dsh-token-meter/client'
@@ -26,7 +27,7 @@ export interface SubagentCatalogInjected {
/** Full props for the session-header catalog action. */
export type SubagentCatalogActionProps =
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
interface CatalogRowsProps {
parentSessionId: SessionId
@@ -42,11 +43,14 @@ interface CatalogRowsProps {
closeCatalog: () => void
}
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
function diagnosticReason(
entry: Extract<CatalogEntry, { kind: 'diagnostic' }>,
t: TranslateNS<typeof NS>,
): string {
switch (entry.reason) {
case 'corrupt': return '会话记录损坏'
case 'unsupported': return '子代理记录版本不受支持'
case 'unavailable': return '会话记录暂不可用'
case 'corrupt': return t('diagnostic.corrupt')
case 'unsupported': return t('diagnostic.unsupported')
case 'unavailable': return t('diagnostic.unavailable')
}
}
@@ -90,19 +94,26 @@ function activityDuration(
}
/** Format a non-negative duration to seconds without dropping larger units. */
function formatDuration(ms: number): string {
function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
const seconds = totalSeconds % 60
const totalMinutes = Math.floor(totalSeconds / 60)
const minutes = totalMinutes % 60
const hours = Math.floor(totalMinutes / 60)
if (hours > 0) {
return `${hours}小时${String(minutes).padStart(2, '0')}${String(seconds).padStart(2, '0')}`
return t('duration.hours', {
hours,
minutes: String(minutes).padStart(2, '0'),
seconds: String(seconds).padStart(2, '0'),
})
}
if (totalMinutes > 0) {
return `${totalMinutes}${String(seconds).padStart(2, '0')}`
return t('duration.minutes', {
minutes: totalMinutes,
seconds: String(seconds).padStart(2, '0'),
})
}
return `${seconds}`
return t('duration.seconds', { seconds })
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
@@ -135,28 +146,30 @@ function CatalogLoadingRows({
parentSessionId,
summaries,
level,
t,
}: {
parentSessionId: SessionId
summaries: Readonly<Record<SessionId, SessionSummary>>
level: number
t: TranslateNS<typeof NS>
}) {
const children = Object.values(summaries).filter(summary => (
summary.origin === 'subagent' && summary.parentId === parentSessionId
))
if (children.length === 0) return <div className={css.notice}></div>
if (children.length === 0) return <div className={css.notice}>{t('loading.label')}</div>
return children.map(summary => (
<div key={summary.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label="正在加载子代理"
aria-label={t('loading.aria')}
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
>
<span className={css.disclosureSpace} />
<StateDot state={summary.running ? 'ongoing' : 'done'} />
<span className={css.content}>
<span className={css.label}></span>
<span className={css.label}>{t('loading.label')}</span>
</span>
</div>
</div>
@@ -166,8 +179,8 @@ function CatalogLoadingRows({
/** 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) {
openChild, refresh, toggleBranch, closeCatalog, t,
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
return (
<>
@@ -176,24 +189,25 @@ function CatalogRows({
parentSessionId={parentSessionId}
summaries={summaries}
level={level}
t={t}
/>
)}
{catalog.state === 'error' && (
<div className={css.error}>
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
<span>{catalog.error?.message ?? t('load.error')}</span>
<button
type="button"
className={css.refresh}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
{t('retry')}
</button>
</div>
)}
{catalog.entries.map((entry) => {
if (entry.kind === 'diagnostic') {
const reason = diagnosticReason(entry)
const reason = diagnosticReason(entry, t)
return (
<div key={entry.id} className={css.node}>
<div
@@ -222,8 +236,8 @@ function CatalogRows({
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive')
const secondary = [summary?.title, mode, activity]
.filter(value => value !== undefined)
.join(' · ')
@@ -236,7 +250,7 @@ function CatalogRows({
)
const metrics = [
totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`,
durationMs === undefined ? undefined : formatDuration(durationMs),
durationMs === undefined ? undefined : formatDuration(durationMs, t),
].filter(value => value !== undefined).join(' · ')
const open = (): void => {
@@ -282,7 +296,7 @@ function CatalogRows({
type="button"
tabIndex={-1}
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })}
onClick={toggle}
>
<IconChevronRightOutline14 />
@@ -309,6 +323,7 @@ function CatalogRows({
parentSessionId={entry.id}
summaries={summaries}
level={level + 1}
t={t}
/>
)
: (
@@ -324,6 +339,7 @@ function CatalogRows({
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
t={t}
/>
)}
</div>
@@ -338,10 +354,10 @@ function CatalogRows({
/**
* 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.
* @returns The action while the catalog is pending or summaries establish descendants.
*/
export function SubagentCatalogAction({
sessionId, useSessions, openChild, refresh, setCatalogOpen,
sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
}: SubagentCatalogActionProps) {
const catalogs = useSessions(state => state.subagentsByParent)
const summaries = useSessions(state => state.byId)
@@ -359,6 +375,20 @@ export function SubagentCatalogAction({
// The catalog can arrive before the session-list baseline; never undercount
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other'
// Session summaries can announce membership before the descriptor-backed catalog catches up.
// Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
const summaryBackedLoading = descendants.count > 0
&& (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
? {
entries: [],
parentAvailable: catalog?.parentAvailable ?? false,
state: 'loading',
error: null,
}
: catalog
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
if (next) observedCatalogs.current.add(parentSessionId)
@@ -432,7 +462,8 @@ export function SubagentCatalogAction({
observedCatalogs.current.clear()
}, [])
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
const visible = presentedCatalog !== undefined
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
useEffect(() => {
if (visible || !open) return
setOpen(false)
@@ -476,7 +507,7 @@ export function SubagentCatalogAction({
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return
@@ -488,14 +519,14 @@ export function SubagentCatalogAction({
<span className={css.activitySlot}>
{descendants.running && <StateDot state="ongoing" />}
</span>
<span className={css.count}>{descendantCount} </span>
<span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open && (
<div className={css.menu} role="tree" aria-label="子代理会话">
<div className={css.menu} role="tree" aria-label={t('tree.aria')}>
<CatalogRows
parentSessionId={sessionId}
catalog={catalog}
catalog={presentedCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
@@ -505,6 +536,7 @@ export function SubagentCatalogAction({
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
t={t}
/>
</div>
)}

View File

@@ -1,4 +1,5 @@
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
import css from './SubagentReadOnlyComposer.module.css'
/** Why a catalog-addressed conversation cannot accept human input. */
@@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch {
/** Full chain props after the read-only subagent selector accepts the owner currency. */
export type SubagentReadOnlyComposerProps =
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale<typeof NS>
/**
* Explain why the normal composer is unavailable for an addressed child.
@@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps =
* @returns A read-only composer replacement.
*/
export function SubagentReadOnlyComposer({
matched,
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
matched, t,
}: Pick<SubagentReadOnlyComposerProps, 'matched' | 't'>) {
const oneShot = matched.reason === 'one-shot'
return (
<div className={css.frame} role="status">
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
<strong>{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')}</strong>
<span>
{oneShot
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
{t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
</span>
</div>
)

View File

@@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from './SubagentReadOnlyComposer.tsx'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { en, NS, zh, type SubagentKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Subagent catalog and read-only composer copy. */
'subagent': SubagentKey
}
}
export type {
SubagentCatalogActionProps, SubagentCatalogInjected,
@@ -27,7 +36,7 @@ export type {
} from './SubagentReadOnlyComposer.tsx'
/** Required services for references, conversation slots, and session navigation. */
export const inject = ['slash', 'sessions', 'conversation', 'slots']
export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale']
/** Claim the composer for one-shot history or an unavailable continuation owner. */
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries')
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.
@@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
order: 10,
locale: NS,
inject: catalogActions,
}, SubagentCatalogAction),
'ui-subagent: lazy descendant catalog action',
@@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.composer',
priority: -10,
locale: NS,
select: selectReadOnlySubagent,
}, SubagentReadOnlyComposer),
'ui-subagent: read-only addressed composer',

View File

@@ -0,0 +1,65 @@
/** `subagent` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'subagent'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'diagnostic.corrupt': '会话记录损坏',
'diagnostic.unsupported': '子代理记录版本不受支持',
'diagnostic.unavailable': '会话记录暂不可用',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
'loading.label': '正在加载子代理…',
'loading.aria': '正在加载子代理',
'load.error': '无法加载子代理',
'retry': '重试',
'mode.oneShot': '一次性',
'mode.continuable': '可继续',
'activity.running': '正在运行',
'activity.inactive': '当前未运行',
'branch.collapse': '收起 {label} 的下级子代理',
'branch.expand': '展开 {label} 的下级子代理',
'count.total.one': '{count} 个子代理',
'count.total.other': '{count} 个子代理',
'count.running.one': '{count} 个子代理,正在运行',
'count.running.other': '{count} 个子代理,正在运行',
'tree.aria': '子代理会话',
'readonly.oneShot.title': '一次性子代理记录',
'readonly.title': '此子代理暂时只读',
'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。',
} as const
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<SubagentKey, string> = {
'diagnostic.corrupt': 'corrupted session record',
'diagnostic.unsupported': 'unsupported subagent record version',
'diagnostic.unavailable': 'session record temporarily unavailable',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'duration.hours': '{hours}h {minutes}m {seconds}s',
'loading.label': 'Loading subagents…',
'loading.aria': 'Loading subagents',
'load.error': 'Unable to load subagents',
'retry': 'Retry',
'mode.oneShot': 'one-shot',
'mode.continuable': 'continuable',
'activity.running': 'running',
'activity.inactive': 'not running',
'branch.collapse': 'Collapse {label} descendants',
'branch.expand': 'Expand {label} descendants',
'count.total.one': '{count} subagent',
'count.total.other': '{count} subagents',
'count.running.one': '{count} subagent running',
'count.running.other': '{count} subagents running',
'tree.aria': 'Subagent sessions',
'readonly.oneShot.title': 'One-shot subagent record',
'readonly.title': 'This subagent is read-only for now',
'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.',
'readonly.body': 'The parent session is offline; reopen it to continue sending messages.',
}
/** Key domain of the `subagent` namespace (zh is the source of truth). */
export type SubagentKey = keyof typeof zh

View File

@@ -18,6 +18,7 @@ import {
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import {
SubagentCatalogAction, type SubagentCatalogInjected,
} from '../src/client/SubagentCatalogAction.tsx'
@@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) {
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', face)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { source: captured!, face, ctx }
}
@@ -111,7 +113,7 @@ const req = (query: string) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots'])
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale'])
})
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
@@ -119,6 +121,7 @@ describe('apply', () => {
await ctx.plugin(SlashService).await()
ctx.provide('sessions', sessionsWith(FAMILY))
await provideSlotFaces(ctx)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -8,6 +9,7 @@ import {
SubagentCatalogAction, type SubagentCatalogActionProps,
} from '../src/client/SubagentCatalogAction.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
@@ -18,6 +20,7 @@ afterEach(() => {
const PARENT = 'parent' as SessionId
const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
const t: SubagentCatalogActionProps['t'] = makeTranslate(zh)
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
return {
@@ -70,6 +73,7 @@ function props(
openChild: vi.fn(),
refresh: vi.fn(),
setCatalogOpen: vi.fn(),
t,
} as unknown as SubagentCatalogActionProps
}
@@ -155,6 +159,24 @@ describe('SubagentCatalogAction', () => {
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
})
it('selects singular count keys for one descendant', () => {
const base = props(catalog({
entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}],
}), {}, {
[CHILD]: {
...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true,
},
})
const translate = vi.fn(base.t)
render(<SubagentCatalogAction {...base} t={translate} />)
expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
})
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentCatalogAction {...input} />)
@@ -416,6 +438,32 @@ describe('SubagentCatalogAction', () => {
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
})
it('keeps known descendants reachable while their catalog is absent or stale-empty', () => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[second]: {
...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true,
},
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentCatalogAction {...absent} />)
const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' })
fireEvent.click(trigger)
expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(absent.openChild).not.toHaveBeenCalled()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentCatalogAction {...staleEmpty} />)
expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy()
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(staleEmpty.openChild).not.toHaveBeenCalled()
})
it('renders empty loading and fallback error states without focusable rows', async () => {
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentCatalogAction {...loading} />)
@@ -469,12 +517,12 @@ describe('SubagentCatalogAction', () => {
describe('SubagentReadOnlyComposer', () => {
it('explains the exact missing-parent recovery path', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
})
})

View File

@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},