fix(web): stabilize nested subagent navigation
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
|
||||||
2026-07-27-web-subagent-conversations.md: 7087bc982b99ef0e967f47dd1d28612fba6aed8c
|
2026-07-27-web-subagent-conversations.md: 1c709f78bc5aaad5cd03959f7fa9f077dc20d8d3
|
||||||
2026-07-27-web-subagent-conversations.zh.md: 732fd757bbe06f81ce7b4fa0919e8c7d18590196
|
2026-07-27-web-subagent-conversations.zh.md: 07c70ce06c4d4f9b5e2d07852d83e26ab9b06369
|
||||||
@@ -39,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
|
|||||||
|
|
||||||
The header action count includes healthy `kind: 'child'` entries and excludes diagnostics. It is absent only after a complete empty response. Every healthy row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. The tree presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
|
The header action count includes healthy `kind: 'child'` entries and excludes diagnostics. It is absent only after a complete empty response. Every healthy row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. The tree presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
|
||||||
|
|
||||||
`running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
|
`running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
|
||||||
|
|
||||||
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
|
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier
|
|||||||
|
|
||||||
## Client object layer and presentation
|
## Client object layer and presentation
|
||||||
|
|
||||||
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. Restored navigation persists the full mode-bearing address.
|
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address.
|
||||||
|
|
||||||
Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`.
|
Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
|||||||
|
|
||||||
页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。每个健康行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
|
页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。每个健康行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
|
||||||
|
|
||||||
`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
||||||
|
|
||||||
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
|
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
|||||||
|
|
||||||
## 客户端对象层与呈现
|
## 客户端对象层与呈现
|
||||||
|
|
||||||
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。恢复的导航会持久化包含 mode 的完整地址。
|
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。
|
||||||
|
|
||||||
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
|
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog {
|
|||||||
error: RpcError | null
|
error: RpcError | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CatalogInflight {
|
||||||
|
readonly promise: Promise<void>
|
||||||
|
readonly expandableRows: Set<SessionId>
|
||||||
|
}
|
||||||
|
|
||||||
type SessionListMutation =
|
type SessionListMutation =
|
||||||
| { kind: 'upsert'; summary: SessionSummary }
|
| { kind: 'upsert'; summary: SessionSummary }
|
||||||
| { kind: 'remove'; sessionId: SessionId }
|
| { kind: 'remove'; sessionId: SessionId }
|
||||||
@@ -94,7 +99,7 @@ export class SessionManager {
|
|||||||
private listMutations: SessionListMutation[] | null = null
|
private listMutations: SessionListMutation[] | null = null
|
||||||
private readonly addresses = new Map<SessionId, SubagentAddress>()
|
private readonly addresses = new Map<SessionId, SubagentAddress>()
|
||||||
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
|
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
|
||||||
private readonly catalogInflight = new Map<SessionId, Promise<void>>()
|
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
|
||||||
private readonly openCatalogs = new Set<SessionId>()
|
private readonly openCatalogs = new Set<SessionId>()
|
||||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
@@ -131,10 +136,11 @@ export class SessionManager {
|
|||||||
* @param sessionId - listed or catalog-addressed Session id.
|
* @param sessionId - listed or catalog-addressed Session id.
|
||||||
*/
|
*/
|
||||||
select(sessionId: SessionId): void {
|
select(sessionId: SessionId): void {
|
||||||
const address = this.addresses.get(sessionId)
|
const address = this.navigationAddress(sessionId)
|
||||||
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
|
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
|
||||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||||
}
|
}
|
||||||
|
if (address !== undefined) this.addresses.set(sessionId, address)
|
||||||
this.sessions.get(sessionId)?.configureSubagent(
|
this.sessions.get(sessionId)?.configureSubagent(
|
||||||
address,
|
address,
|
||||||
address === undefined
|
address === undefined
|
||||||
@@ -178,6 +184,23 @@ export class SessionManager {
|
|||||||
return this.addresses.get(sessionId)
|
return this.addresses.get(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an address for breadcrumb navigation without retaining transport authority.
|
||||||
|
* @param sessionId - possible child id in an already-loaded catalog.
|
||||||
|
* @returns A retained or catalog-derived direct-parent address.
|
||||||
|
*/
|
||||||
|
navigationAddress(sessionId: SessionId): SubagentAddress | undefined {
|
||||||
|
const retained = this.addresses.get(sessionId)
|
||||||
|
if (retained !== undefined) return retained
|
||||||
|
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||||
|
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId)
|
||||||
|
if (child?.kind === 'child') {
|
||||||
|
return { parentSessionId, childSessionId: sessionId, mode: child.mode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Instance management ----
|
// ---- Instance management ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,8 +285,9 @@ export class SessionManager {
|
|||||||
*/
|
*/
|
||||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||||
const existing = this.catalogInflight.get(parentSessionId)
|
const existing = this.catalogInflight.get(parentSessionId)
|
||||||
if (existing !== undefined) return existing
|
if (existing !== undefined) return existing.promise
|
||||||
const previous = this.catalogs.get(parentSessionId)
|
const previous = this.catalogs.get(parentSessionId)
|
||||||
|
const expandableRows = new Set<SessionId>()
|
||||||
this.catalogs.set(parentSessionId, {
|
this.catalogs.set(parentSessionId, {
|
||||||
entries: previous?.entries ?? [],
|
entries: previous?.entries ?? [],
|
||||||
parentAvailable: previous?.parentAvailable ?? false,
|
parentAvailable: previous?.parentAvailable ?? false,
|
||||||
@@ -277,6 +301,7 @@ export class SessionManager {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
this.catalogs.set(parentSessionId, {
|
this.catalogs.set(parentSessionId, {
|
||||||
...result.value,
|
...result.value,
|
||||||
|
entries: this.withExpandableRows(result.value.entries, expandableRows),
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
@@ -286,7 +311,7 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.catalogs.set(parentSessionId, {
|
this.catalogs.set(parentSessionId, {
|
||||||
entries: previous?.entries ?? [],
|
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
|
||||||
parentAvailable: previous?.parentAvailable ?? false,
|
parentAvailable: previous?.parentAvailable ?? false,
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: result.error,
|
error: result.error,
|
||||||
@@ -295,7 +320,7 @@ export class SessionManager {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const folded = transportError<never>(error)
|
const folded = transportError<never>(error)
|
||||||
this.catalogs.set(parentSessionId, {
|
this.catalogs.set(parentSessionId, {
|
||||||
entries: previous?.entries ?? [],
|
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
|
||||||
parentAvailable: previous?.parentAvailable ?? false,
|
parentAvailable: previous?.parentAvailable ?? false,
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: folded.ok ? null : folded.error,
|
error: folded.ok ? null : folded.error,
|
||||||
@@ -305,7 +330,7 @@ export class SessionManager {
|
|||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
this.catalogInflight.set(parentSessionId, operation)
|
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows })
|
||||||
return operation
|
return operation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,8 +742,14 @@ export class SessionManager {
|
|||||||
if (changed) this.notifier.markDirty()
|
if (changed) this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a loaded parent row expandable after one direct subagent publishes. */
|
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
|
||||||
private markCatalogParentExpandable(parentSessionId: SessionId): void {
|
private markCatalogParentExpandable(parentSessionId: SessionId): void {
|
||||||
|
this.applyCatalogParentExpandable(parentSessionId)
|
||||||
|
for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
|
||||||
|
private applyCatalogParentExpandable(parentSessionId: SessionId): void {
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const [catalogParentId, catalog] of this.catalogs) {
|
for (const [catalogParentId, catalog] of this.catalogs) {
|
||||||
if (!catalog.entries.some(entry =>
|
if (!catalog.entries.some(entry =>
|
||||||
@@ -733,6 +764,16 @@ export class SessionManager {
|
|||||||
if (changed) this.notifier.markDirty()
|
if (changed) this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fold request-local positive row mutations into one catalog result before publication. */
|
||||||
|
private withExpandableRows(
|
||||||
|
entries: SubagentCatalog['entries'],
|
||||||
|
expandableRows: ReadonlySet<SessionId>,
|
||||||
|
): SubagentCatalog['entries'] {
|
||||||
|
return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id)
|
||||||
|
? { ...entry, hasChildren: true }
|
||||||
|
: entry)
|
||||||
|
}
|
||||||
|
|
||||||
private buildListSnapshot(): SessionListSnapshot {
|
private buildListSnapshot(): SessionListSnapshot {
|
||||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||||
// List rows read the generic 'title' projection key (host-computed unit
|
// List rows read the generic 'title' projection key (host-computed unit
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||||
* id), stable SessionBinding cache, ancestry walk.
|
* id), stable SessionBinding cache, breadcrumb-route projection.
|
||||||
*
|
*
|
||||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||||
* resolution (pure — resolution has no side effects and is render-safe);
|
* resolution (pure — resolution has no side effects and is render-safe);
|
||||||
@@ -206,7 +206,7 @@ export interface SessionProvideDescriptor {
|
|||||||
resolve(binding: SessionBinding): SessionProvideContribution
|
resolve(binding: SessionBinding): SessionProvideContribution
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
|
||||||
export class SessionsService implements ISessions {
|
export class SessionsService implements ISessions {
|
||||||
/**
|
/**
|
||||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||||
@@ -563,25 +563,6 @@ export class SessionsService implements ISessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Breadcrumb feed: walk subagent parent links inside the list store.
|
|
||||||
* @param id - session id.
|
|
||||||
* @returns The ordinary owner plus its subagent route, or only the requested ordinary/fork session.
|
|
||||||
*/
|
|
||||||
ancestry(id: SessionId): SessionSummary[] {
|
|
||||||
const { byId } = this.list.getSnapshot()
|
|
||||||
const chain: SessionSummary[] = []
|
|
||||||
let cursor: SessionId | undefined = id
|
|
||||||
while (cursor !== undefined) {
|
|
||||||
const summary: SessionSummary | undefined = byId[cursor]
|
|
||||||
if (summary === undefined || chain.includes(summary)) break
|
|
||||||
chain.unshift(summary)
|
|
||||||
if (summary.origin !== 'subagent') break
|
|
||||||
cursor = summary.parentId
|
|
||||||
}
|
|
||||||
return chain
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||||
* prune share one predicate (decision 12): listed on the host or selected
|
* prune share one predicate (decision 12): listed on the host or selected
|
||||||
@@ -660,7 +641,7 @@ export class SessionsService implements ISessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (byId[address.parentSessionId] !== undefined) break
|
if (byId[address.parentSessionId] !== undefined) break
|
||||||
address = this.manager.subagentAddress(address.parentSessionId)
|
address = this.manager.navigationAddress(address.parentSessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const persisted = this.selection.getSnapshot().sessionId
|
const persisted = this.selection.getSnapshot().sessionId
|
||||||
|
|||||||
@@ -426,6 +426,47 @@ describe('subagent catalogs', () => {
|
|||||||
{ kind: 'child', id: S2, hasChildren: false },
|
{ kind: 'child', id: S2, hasChildren: false },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
const root = 'fk-root' as SessionId
|
||||||
|
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||||
|
api.onSubagentList = () => response.promise
|
||||||
|
const manager = new SessionManager(api)
|
||||||
|
const refresh = manager.refreshSubagents(root)
|
||||||
|
|
||||||
|
manager.handleHostEnvelope({
|
||||||
|
rpcId: 'nested-subagent' as never,
|
||||||
|
payload: {
|
||||||
|
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
|
||||||
|
parentSessionId: S1, origin: 'subagent', blank: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response.resolve(ok({
|
||||||
|
entries: [{
|
||||||
|
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||||
|
activity: 'inactive', hasChildren: false,
|
||||||
|
}] as never[],
|
||||||
|
parentAvailable: true,
|
||||||
|
}))
|
||||||
|
await refresh
|
||||||
|
|
||||||
|
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||||
|
{ kind: 'child', id: S1, hasChildren: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
api.onSubagentList = () => Promise.resolve(ok({
|
||||||
|
entries: [{
|
||||||
|
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||||
|
activity: 'inactive', hasChildren: false,
|
||||||
|
}] as never[],
|
||||||
|
parentAvailable: true,
|
||||||
|
}))
|
||||||
|
await manager.refreshSubagents(root)
|
||||||
|
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||||
|
{ kind: 'child', id: S1, hasChildren: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('remaining branches', () => {
|
describe('remaining branches', () => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* with derived titles), the migrated current-selection account (open
|
* with derived titles), the migrated current-selection account (open
|
||||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||||
* deferral — the stage follows list.current), binding identity, ancestry
|
* deferral — the stage follows list.current), binding identity, breadcrumb
|
||||||
* walk, create.
|
* projection, create.
|
||||||
*/
|
*/
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -361,24 +361,8 @@ describe('slot-store scope prune hook', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ancestry', () => {
|
describe('catalog-addressed navigation', () => {
|
||||||
it('walks only subagent lineage and includes its first ordinary owner', async () => {
|
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
|
||||||
const b = bench()
|
|
||||||
await feedList(b, [
|
|
||||||
{ id: 'root', cwd: '/w/app' },
|
|
||||||
{ id: 'fork', parentId: 'root' },
|
|
||||||
{ id: 'child', parentId: 'fork', origin: 'subagent' },
|
|
||||||
{ id: 'grandchild', parentId: 'child', origin: 'subagent' },
|
|
||||||
{ id: 'orphan', parentId: 'ghost', origin: 'subagent' },
|
|
||||||
])
|
|
||||||
expect(b.svc.ancestry(sid('fork')).map(s => s.id)).toEqual(['fork'])
|
|
||||||
expect(b.svc.ancestry(sid('child')).map(s => s.id)).toEqual(['fork', 'child'])
|
|
||||||
expect(b.svc.ancestry(sid('grandchild')).map(s => s.id)).toEqual(['fork', 'child', 'grandchild'])
|
|
||||||
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
|
|
||||||
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retains a cold nested subagent route without retaining ancestor scopes', async () => {
|
|
||||||
const b = bench()
|
const b = bench()
|
||||||
b.api.onSubagentList = (payload) => {
|
b.api.onSubagentList = (payload) => {
|
||||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||||
@@ -402,26 +386,19 @@ describe('ancestry', () => {
|
|||||||
}
|
}
|
||||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||||
}
|
}
|
||||||
await feedList(b, [
|
await feedList(b, [{ id: 'root' }])
|
||||||
{ id: 'root' },
|
|
||||||
{ id: 'child', parentId: 'root', origin: 'subagent' },
|
|
||||||
{ id: 'grandchild', parentId: 'child', origin: 'subagent' },
|
|
||||||
])
|
|
||||||
await b.svc.refreshSubagents(sid('root'))
|
await b.svc.refreshSubagents(sid('root'))
|
||||||
b.svc.openSubagent({
|
|
||||||
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
|
|
||||||
})
|
|
||||||
await b.svc.refreshSubagents(sid('child'))
|
await b.svc.refreshSubagents(sid('child'))
|
||||||
b.svc.openSubagent({
|
b.svc.openSubagent({
|
||||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||||
})
|
})
|
||||||
|
|
||||||
await feedList(b, [{ id: 'root' }])
|
|
||||||
const list = b.svc.list.getSnapshot()
|
const list = b.svc.list.getSnapshot()
|
||||||
expect(list.ids).toEqual([sid('root')])
|
expect(list.ids).toEqual([sid('root')])
|
||||||
expect(b.svc.ancestry(sid('grandchild')).map(summary => summary.id))
|
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
|
||||||
.toEqual([sid('root'), sid('child'), sid('grandchild')])
|
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
|
||||||
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
||||||
|
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
|
||||||
|
|
||||||
b.svc.open(sid('child'))
|
b.svc.open(sid('child'))
|
||||||
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
|
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
|
||||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
|
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
|
||||||
import css from './ConversationRoot.module.css'
|
import css from './ConversationRoot.module.css'
|
||||||
@@ -10,19 +9,35 @@ import css from './ConversationRoot.module.css'
|
|||||||
/** Full props composed from the strict session slot contract. */
|
/** Full props composed from the strict session slot contract. */
|
||||||
export type ConversationSessionProps = ConversationSessionSlotProps
|
export type ConversationSessionProps = ConversationSessionSlotProps
|
||||||
|
|
||||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
|
interface Breadcrumb {
|
||||||
const chain: SessionSummary[] = []
|
readonly id: SessionId
|
||||||
|
readonly displayTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
|
||||||
|
const chain: Breadcrumb[] = []
|
||||||
|
const seen = new Set<SessionId>()
|
||||||
let cursor: SessionId | undefined = id
|
let cursor: SessionId | undefined = id
|
||||||
while (cursor !== undefined) {
|
while (cursor !== undefined) {
|
||||||
|
if (seen.has(cursor)) break
|
||||||
|
seen.add(cursor)
|
||||||
const summary: SessionSummary | undefined = list.byId[cursor]
|
const summary: SessionSummary | undefined = list.byId[cursor]
|
||||||
if (summary === undefined || chain.includes(summary)) break
|
if (summary === undefined) break
|
||||||
chain.unshift(summary)
|
chain.unshift({ id: summary.id, displayTitle: summary.displayTitle })
|
||||||
if (summary.origin !== 'subagent') break
|
if (summary.origin !== 'subagent') break
|
||||||
cursor = summary.parentId
|
cursor = summary.parentId
|
||||||
}
|
}
|
||||||
return chain
|
return chain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrumb[]): boolean {
|
||||||
|
return left.length === right.length
|
||||||
|
&& left.every((item, index) => {
|
||||||
|
const other = right.at(index)
|
||||||
|
return other !== undefined && item.id === other.id && item.displayTitle === other.displayTitle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function ConversationSession({
|
export function ConversationSession({
|
||||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||||
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
|
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
|
||||||
@@ -31,7 +46,7 @@ export function ConversationSession({
|
|||||||
const tabs = views.list()
|
const tabs = views.list()
|
||||||
const activeId = useStore(s => s.view) ?? 'chat'
|
const activeId = useStore(s => s.view) ?? 'chat'
|
||||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
||||||
const composerPhase = useSession(s => s.composerPhase)
|
const composerPhase = useSession(s => s.composerPhase)
|
||||||
const blank = useSession(s => s.blank)
|
const blank = useSession(s => s.blank)
|
||||||
const inputState = useInput(s => s)
|
const inputState = useInput(s => s)
|
||||||
|
|||||||
@@ -274,8 +274,8 @@ export interface SessionsApi {
|
|||||||
* target and `parentSessionId` lineage; the seed prefix carries the source
|
* target and `parentSessionId` lineage; the seed prefix carries the source
|
||||||
* title. Reading the source uses attached state or persistence inspection
|
* title. Reading the source uses attached state or persistence inspection
|
||||||
* without acquiring an Agent. Workspace attachment follows the source
|
* without acquiring an Agent. Workspace attachment follows the source
|
||||||
* directly, or the nearest
|
* directly, or the nearest workspace-owning ancestor when the source is a
|
||||||
* workspace-owning ancestor when the source is a subagent.
|
* subagent.
|
||||||
*/
|
*/
|
||||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||||
|
|||||||
Reference in New Issue
Block a user