From b94d2f9c1d80f35d051d05c831278ffb50974c14 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 18:23:07 +0800 Subject: [PATCH] fix(web): stabilize nested subagent navigation --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 4 +- ...026-07-27-web-subagent-conversations.zh.md | 4 +- .../runtime/src/client/sessions/manager.ts | 55 ++++++++++++++++--- .../runtime/src/client/sessions/service.ts | 25 +-------- packages/client/runtime/tests/manager.spec.ts | 41 ++++++++++++++ .../runtime/tests/sessions-service.spec.ts | 39 +++---------- .../client/skeleton/ConversationSession.tsx | 27 +++++++-- packages/host/apiproxy/src/api/sessions.ts | 4 +- 9 files changed, 129 insertions(+), 74 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index d8b35fae96..43863edd8b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -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 .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.zh.md: 732fd757bbe06f81ce7b4fa0919e8c7d18590196 +2026-07-27-web-subagent-conversations.md: 1c709f78bc5aaad5cd03959f7fa9f077dc20d8d3 +2026-07-27-web-subagent-conversations.zh.md: 07c70ce06c4d4f9b5e2d07852d83e26ab9b06369 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 7087bc982b..1c709f78bc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -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. -`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. @@ -65,7 +65,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier ## 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`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 732fd757bb..07c70ce06c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -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 行显示。 -`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 以及焦点恢复。 @@ -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`。 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index fa2a8c4a46..4f68648cca 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -55,6 +55,11 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog { error: RpcError | null } +interface CatalogInflight { + readonly promise: Promise + readonly expandableRows: Set +} + type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } @@ -94,7 +99,7 @@ export class SessionManager { private listMutations: SessionListMutation[] | null = null private readonly addresses = new Map() private readonly catalogs = new Map() - private readonly catalogInflight = new Map>() + private readonly catalogInflight = new Map() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() @@ -131,10 +136,11 @@ export class SessionManager { * @param sessionId - listed or catalog-addressed Session id. */ 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) { throw new Error(`sessions.select: unknown session ${sessionId}`) } + if (address !== undefined) this.addresses.set(sessionId, address) this.sessions.get(sessionId)?.configureSubagent( address, address === undefined @@ -178,6 +184,23 @@ export class SessionManager { 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 ---- /** @@ -262,8 +285,9 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) return existing + if (existing !== undefined) return existing.promise const previous = this.catalogs.get(parentSessionId) + const expandableRows = new Set() this.catalogs.set(parentSessionId, { entries: previous?.entries ?? [], parentAvailable: previous?.parentAvailable ?? false, @@ -277,6 +301,7 @@ export class SessionManager { if (result.ok) { this.catalogs.set(parentSessionId, { ...result.value, + entries: this.withExpandableRows(result.value.entries, expandableRows), state: 'ready', error: null, }) @@ -286,7 +311,7 @@ export class SessionManager { } } else { this.catalogs.set(parentSessionId, { - entries: previous?.entries ?? [], + entries: this.withExpandableRows(previous?.entries ?? [], expandableRows), parentAvailable: previous?.parentAvailable ?? false, state: 'error', error: result.error, @@ -295,7 +320,7 @@ export class SessionManager { } catch (error: unknown) { const folded = transportError(error) this.catalogs.set(parentSessionId, { - entries: previous?.entries ?? [], + entries: this.withExpandableRows(previous?.entries ?? [], expandableRows), parentAvailable: previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, @@ -305,7 +330,7 @@ export class SessionManager { this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, operation) + this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows }) return operation } @@ -717,8 +742,14 @@ export class SessionManager { 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 { + 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 for (const [catalogParentId, catalog] of this.catalogs) { if (!catalog.entries.some(entry => @@ -733,6 +764,16 @@ export class SessionManager { if (changed) this.notifier.markDirty() } + /** Fold request-local positive row mutations into one catalog result before publication. */ + private withExpandableRows( + entries: SubagentCatalog['entries'], + expandableRows: ReadonlySet, + ): SubagentCatalog['entries'] { + return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id) + ? { ...entry, hasChildren: true } + : entry) + } + private buildListSnapshot(): SessionListSnapshot { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 42c3de31e2..6b65096fa8 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -4,7 +4,7 @@ * session-scoped surface keys off — migrated here from ui-layout per the * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin * 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 * resolution (pure — resolution has no side effects and is render-safe); @@ -206,7 +206,7 @@ export interface SessionProvideDescriptor { 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 { /** * 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 * 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 - address = this.manager.subagentAddress(address.parentSessionId) + address = this.manager.navigationAddress(address.parentSessionId) } } const persisted = this.selection.getSnapshot().sessionId diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 31874bcee6..72cef9d6f9 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -426,6 +426,47 @@ describe('subagent catalogs', () => { { 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>>() + 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', () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 61efe6e1ed..587e64d0e0 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -3,8 +3,8 @@ * with derived titles), the migrated current-selection account (open * validation, persisted mask semantics, cell resolution), scope-tree * lifecycle (lazy mint / frozen survival / removed teardown with staged - * deferral — the stage follows list.current), binding identity, ancestry - * walk, create. + * deferral — the stage follows list.current), binding identity, breadcrumb + * projection, create. */ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -361,24 +361,8 @@ describe('slot-store scope prune hook', () => { }) }) -describe('ancestry', () => { - it('walks only subagent lineage and includes its first ordinary owner', 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 () => { +describe('catalog-addressed navigation', () => { + it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => { const b = bench() b.api.onSubagentList = (payload) => { const { parentSessionId } = payload as { parentSessionId: SessionId } @@ -402,26 +386,19 @@ describe('ancestry', () => { } return Promise.resolve(ok({ entries: [], parentAvailable: false })) } - await feedList(b, [ - { id: 'root' }, - { id: 'child', parentId: 'root', origin: 'subagent' }, - { id: 'grandchild', parentId: 'child', origin: 'subagent' }, - ]) + await feedList(b, [{ id: 'root' }]) await b.svc.refreshSubagents(sid('root')) - b.svc.openSubagent({ - parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable', - }) await b.svc.refreshSubagents(sid('child')) b.svc.openSubagent({ parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable', }) - await feedList(b, [{ id: 'root' }]) const list = b.svc.list.getSnapshot() expect(list.ids).toEqual([sid('root')]) - expect(b.svc.ancestry(sid('grandchild')).map(summary => summary.id)) - .toEqual([sid('root'), sid('child'), sid('grandchild')]) + expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' }) + expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' }) expect(b.svc.binding(sid('child'))).toBeUndefined() + expect(b.svc.subagentAddress(sid('child'))).toBeUndefined() b.svc.open(sid('child')) expect(b.svc.list.getSnapshot().current).toBe(sid('child')) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index df0e95d1d0..d6a597e54f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -2,7 +2,6 @@ import { useEffect, useSyncExternalStore, type ReactNode } from 'react' 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 { ConversationSessionSlotProps } from '../contract/slots.ts' 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. */ export type ConversationSessionProps = ConversationSessionSlotProps -function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] { - const chain: SessionSummary[] = [] +interface Breadcrumb { + readonly id: SessionId + readonly displayTitle: string +} + +function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] { + const chain: Breadcrumb[] = [] + const seen = new Set() let cursor: SessionId | undefined = id while (cursor !== undefined) { + if (seen.has(cursor)) break + seen.add(cursor) const summary: SessionSummary | undefined = list.byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) + if (summary === undefined) break + chain.unshift({ id: summary.id, displayTitle: summary.displayTitle }) if (summary.origin !== 'subagent') break cursor = summary.parentId } 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({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, @@ -31,7 +46,7 @@ export function ConversationSession({ const tabs = views.list() const activeId = useStore(s => s.view) ?? 'chat' 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 blank = useSession(s => s.blank) const inputState = useInput(s => s) diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 99e725482e..e19ab08ef5 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -274,8 +274,8 @@ export interface SessionsApi { * target and `parentSessionId` lineage; the seed prefix carries the source * title. Reading the source uses attached state or persistence inspection * without acquiring an Agent. Workspace attachment follows the source - * directly, or the nearest - * workspace-owning ancestor when the source is a subagent. + * directly, or the nearest workspace-owning ancestor when the source is a + * subagent. */ fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise>