Browse Source

Merge pull request #2856 from deepseek-harness/ihsiang/subagent-header-switcher

feat(web): refine nested subagent header navigation
ihsiang 1 month ago
parent
commit
0864878cd0
31 changed files with 844 additions and 187 deletions
  1. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  3. 1 1
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  4. 2 2
      apps/web/tests/agent-preset-selection.e2e.ts
  5. 4 3
      apps/web/tests/snapshots/agent-preset-selection/header.expected.md
  6. 2 2
      apps/web/tests/snapshots/subagent-conversation/nested.expected.md
  7. 4 4
      apps/web/tests/snapshots/subagent-conversation/ui.expected.md
  8. 1 1
      apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md
  9. 20 13
      apps/web/tests/subagent-conversation.e2e.ts
  10. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  11. 0 0
      packages/client/ui-conversation/README.md
  12. 0 0
      packages/client/ui-conversation/README.zh.md
  13. 1 0
      packages/client/ui-conversation/src/client/apply.ts
  14. 25 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  15. 2 1
      packages/client/ui-conversation/src/client/index.ts
  16. 5 0
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  17. 44 9
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  18. 2 0
      packages/client/ui-conversation/tests/chat-apply.client.spec.tsx
  19. 43 7
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  20. 2 2
      packages/client/ui-subagent/README.i18n.yaml
  21. 1 1
      packages/client/ui-subagent/README.md
  22. 1 1
      packages/client/ui-subagent/README.zh.md
  23. 3 1
      packages/client/ui-subagent/package.json
  24. 61 8
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
  25. 285 43
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
  26. 6 8
      packages/client/ui-subagent/src/client/index.ts
  27. 2 0
      packages/client/ui-subagent/src/client/locales.ts
  28. 5 5
      packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
  29. 257 47
      packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
  30. 54 22
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  31. 6 0
      pnpm-lock.yaml

+ 2 - 2
.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: d20e55429d04d4308ae26d001575e664058c7dea
-2026-07-27-web-subagent-conversations.zh.md: 057dccb2543b47c5347ee3c5cc71a9be36b4ea7e
+2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95
+2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2

File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md


File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md


+ 2 - 2
apps/web/tests/agent-preset-selection.e2e.ts

@@ -282,8 +282,8 @@ describe('web e2e: agent-preset selection', () => {
     await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
     expect(snapshot).toContain('Minimal mode')
     expect(snapshot).toContain('button "1 subagent"')
-    expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
-    expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"'))
+    expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
+    expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"'))
     // Static chrome, not a control: the header can only report a composition
     // the host would refuse to change.
     expect(snapshot).not.toContain('button "Minimal mode"')

+ 4 - 3
apps/web/tests/snapshots/agent-preset-selection/header.expected.md

@@ -1,10 +1,11 @@
 - navigation "Session hierarchy":
   - button "Seeded turn" [disabled]
+  - text: /
+  - button "1 subagent":
+    - text: 1 subagent
+    - img
 - img
 - text: Minimal mode
-- button "1 subagent":
-  - text: 1 subagent
-  - img
 - button "Session log":
   - text: Session log
   - img

+ 2 - 2
apps/web/tests/snapshots/subagent-conversation/nested.expected.md

@@ -2,9 +2,9 @@
   - navigation "Session hierarchy":
     - button "Ask a research subagent to"
     - text: /
-    - button "event-sourcing researcher"
+    - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher
     - text: /
-    - button "example editor" [disabled]
+    - 'button "Switch subagent: example editor"': example editor
   - button "Session log":
     - text: Session log
     - img

+ 4 - 4
apps/web/tests/snapshots/subagent-conversation/ui.expected.md

@@ -2,12 +2,12 @@
   - navigation "Session hierarchy":
     - button "Ask a research subagent to"
     - text: /
-    - button "event-sourcing researcher" [disabled]
+    - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher
+    - button "1 subagent":
+      - text: 1 subagent
+      - img
   - img
   - text: Standard mode
-  - button "1 subagent":
-    - text: 1 subagent
-    - img
   - button "Session log":
     - text: Session log
     - img

+ 1 - 1
apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md

@@ -2,7 +2,7 @@
   - navigation "Session hierarchy":
     - button "Ask a research subagent to"
     - text: /
-    - button "event-sourcing researcher" [disabled]
+    - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher
   - img
   - text: Standard mode
   - button "Session log":

+ 20 - 13
apps/web/tests/subagent-conversation.e2e.ts

@@ -226,7 +226,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     const catalogButton = page.getByRole('button', { name: /subagents/ })
     await catalogButton.waitFor({ timeout: 15_000 })
-    await catalogButton.click()
+    await catalogButton.hover()
     const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
     await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
     await catalogTree.press('Escape')
@@ -280,7 +280,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
       acknowledgeReloadConnectionLoss(tripwire, warningStart)
 
-      await page.getByRole('button', { name: '3 subagents' }).click()
+      await page.getByRole('button', { name: '3 subagents' }).hover()
       await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
       const tree = page.getByRole('tree', { name: 'Subagent sessions' })
       await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
@@ -301,7 +301,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
 
   it('expands a persisted grandchild progressively without activating either level', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
+    const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
+    expect(await catalogTree.evaluate((element) => {
+      const rect = element.getBoundingClientRect()
+      const hit = document.elementFromPoint(rect.left + 8, rect.top + 8)
+      return hit !== null && element.contains(hit)
+    })).toBe(true)
     expect(await page.getByRole('button', {
       name: `Expand ${ONE_SHOT_LABEL} descendants`,
     }).count()).toBe(0)
@@ -327,7 +333,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
 
   it('opens the completed child from persistence without activating it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     await expect.poll(
       () => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
@@ -337,7 +343,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
     }
     const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
-    await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
+    await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor()
     const sidebar = await captureStableAria(
       page,
       '[role="tree"][aria-label="Sessions"]',
@@ -383,11 +389,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
 
   it('opens an unavailable persisted grandchild after recording the available child', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
-    await page.getByRole('button', { name: '1 subagent' }).click()
+    await page.getByRole('button', { name: '1 subagent' }).hover()
     const tree = page.getByRole('tree', { name: 'Subagent sessions' })
-    const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
-    expect(await nestedRow.locator(':scope > *').count()).toBe(1)
-    const clickArea = nestedRow.locator(':scope > *')
+    const nestedRow = tree.locator('[role="treeitem"]').filter({ hasText: NESTED_LABEL })
+    await nestedRow.waitFor({ timeout: 15_000 })
+    const clickArea = nestedRow.locator(':scope > div')
+    expect(await clickArea.count()).toBe(1)
     const [treeBox, clickAreaBox] = await Promise.all([
       tree.boundingBox(),
       clickArea.boundingBox(),
@@ -429,7 +436,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       .getByRole('treeitem')
       .last()
     await parentSession.click()
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
     await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
     expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
@@ -440,7 +447,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await page.getByRole('tree', { name: 'Sessions' })
       .getByRole('treeitem', { name: /Ask a research subagent to/ })
       .click()
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
     const forkResponse = page.waitForResponse(response =>
@@ -466,7 +473,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
     const sessions = page.getByRole('tree', { name: 'Sessions' })
     await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     await page.locator('textarea:enabled').first().waitFor()
     expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
@@ -483,7 +490,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
 
     await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
-    await page.getByRole('button', { name: '3 subagents' }).click()
+    await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     const input = page.locator('textarea:enabled').first()
     await input.waitFor()

+ 2 - 2
packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md
-README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a
-README.zh.md: a2d514af398f8a023f21b3308477bb6236bf6786
+README.md: 266cc3cb2308815827031b8e294a06b64fb958dc
+README.zh.md: 9bfa227df014ea68c87f4761a0aeb8731a7924f5

File diff suppressed because it is too large
+ 0 - 0
packages/client/ui-conversation/README.md


File diff suppressed because it is too large
+ 0 - 0
packages/client/ui-conversation/README.zh.md


+ 1 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
     name: 'conversation.session.header',
     locale: NS,
     children: {
+      'conversation.session.header.lineage': { kind: 'single', scope: 'session' },
       'conversation.session.header.actions': { kind: 'list', scope: 'session' },
       'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
     },

+ 25 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -77,6 +77,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * takes every action entry down with it.
      */
     'conversation.session.header': { kind: 'single'; scope: 'session' }
+    /**
+     * One breadcrumb title and its lineage controls. The render site keeps
+     * the ordinary title as fallback; an occupant receives plain title data
+     * and may replace a subagent title with one combined navigation control.
+     */
+    'conversation.session.header.lineage': {
+      kind: 'single'
+      scope: 'session'
+      owner: ConversationHeaderLineageOwnerProps
+    }
     /**
      * One button in the session header's action row — the additive way to put
      * a per-session control beside the title without replacing the header.
@@ -305,6 +315,16 @@ export interface ConversationSessionOwnerProps {
 /** Header actions derive their state from the standard session/global kit. */
 export interface ConversationHeaderActionOwnerProps {}
 
+/** Plain breadcrumb data handed to the optional lineage renderer. */
+export interface ConversationHeaderLineageOwnerProps {
+  /** Session represented by this breadcrumb title. */
+  lineageSessionId: SessionId
+  /** Display title available to a renderer that combines the title with a control. */
+  displayTitle: string
+  /** Navigate to an ancestor title when its combined control is clicked. */
+  openTitle?: () => void
+}
+
 /**
  * The input-region slot currency: dock/left/right entries read
  * the conversation snapshot and the live input state as owner props (both
@@ -640,7 +660,11 @@ export type ConversationSessionSlotProps =
 /** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
 export type ConversationSessionHeaderSlotProps =
   PropsRuntime<'conversation.session.header'>
-  & PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
+  & PropsRenderSlots<
+    'conversation.session.header.lineage'
+    | 'conversation.session.header.actions'
+    | 'conversation.session.header.utilities'
+  >
   & PropsStore<ChatStore>
   & ConversationSessionHeaderInjected
   & PropsLocale<'conversation'>

+ 2 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -31,7 +31,8 @@ export type {
   ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
   ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
   ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected,
-  ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
+  ConversationHeaderLineageOwnerProps, ConversationSessionHeaderInjected, ConversationSessionInjected,
+  ConversationSlotProps, ConvViewOwnerProps,
   ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, HeroBrandMarkOwnerProps,
   MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData,
 } from './contract/slots.ts'

+ 5 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -103,6 +103,11 @@
   cursor: pointer;
 }
 
+.crumbSubagent {
+  font-size: 12px;
+  line-height: 18px;
+}
+
 .crumb:hover:not(:disabled) {
   background: var(--dsw-alias-interactive-bg-hover);
 }

+ 44 - 9
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -18,6 +18,7 @@ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
 interface Breadcrumb {
   readonly id: SessionId
   readonly displayTitle: string
+  readonly subagent: boolean
 }
 
 const DEFAULT_VIEW_ID = 'chat'
@@ -38,7 +39,11 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcr
     seen.add(cursor)
     const summary: SessionSummary | undefined = list.byId[cursor]
     if (summary === undefined) break
-    chain.unshift({ id: summary.id, displayTitle: summary.displayTitle })
+    chain.unshift({
+      id: summary.id,
+      displayTitle: summary.displayTitle,
+      subagent: summary.origin === 'subagent',
+    })
     if (summary.origin !== 'subagent') break
     cursor = summary.parentId
   }
@@ -83,17 +88,47 @@ export function ConversationSessionHeader({
               <nav className={css.crumbs} aria-label={t('session.hierarchy')}>
                 {ancestry.map((summary, index) => {
                   const last = index === ancestry.length - 1
+                  const title = (
+                    <button
+                      type="button"
+                      className={clsx(
+                        css.crumb,
+                        summary.subagent && css.crumbSubagent,
+                        last && css.crumbCurrent,
+                      )}
+                      disabled={last}
+                      onClick={() => { open(summary.id) }}
+                    >
+                      {summary.displayTitle}
+                    </button>
+                  )
+                  const lineage = last || summary.subagent
+                  const lineageOwner = {
+                    lineageSessionId: summary.id,
+                    displayTitle: summary.displayTitle,
+                    ...last ? {} : { openTitle: () => { open(summary.id) } },
+                  }
                   return (
                     <span key={summary.id} className={css.crumbSeg}>
                       {index > 0 && <span className={css.crumbSep}>/</span>}
-                      <button
-                        type="button"
-                        className={clsx(css.crumb, last && css.crumbCurrent)}
-                        disabled={last}
-                        onClick={() => { open(summary.id) }}
-                      >
-                        {summary.displayTitle}
-                      </button>
+                      {lineage
+                        ? summary.subagent
+                          ? renderSlot(
+                            'conversation.session.header.lineage',
+                            lineageOwner,
+                            { fallback: title },
+                          )
+                          : (
+                            <>
+                              {title}
+                              {renderSlot(
+                                'conversation.session.header.lineage',
+                                lineageOwner,
+                                { fallback: null },
+                              )}
+                            </>
+                          )
+                        : title}
                     </span>
                   )
                 })}

+ 2 - 0
packages/client/ui-conversation/tests/chat-apply.client.spec.tsx

@@ -94,6 +94,8 @@ describe('apply wiring', () => {
     expect(b.slots.spec('conversation.hero.brand.mark')).toEqual({ kind: 'single', scope: 'root' })
     expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
     expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
+    expect(b.slots.spec('conversation.session.header.lineage'))
+      .toEqual({ kind: 'single', scope: 'session' })
     expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
     await b.runtime.dispose()
   })

+ 43 - 7
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -3,6 +3,7 @@
 // hero (blank session) and active phases — same textarea DOM node, machine-
 // owned draft, and the hero workspace picker (switching = retargetWorkspace).
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ReactNode } from 'react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import {
@@ -26,7 +27,7 @@ import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
 import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import type {
-  ComposerBarOwnerProps,
+  ComposerBarOwnerProps, ConversationHeaderLineageOwnerProps,
 } from '../src/client/contract/slots.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 
@@ -96,6 +97,8 @@ function mount(
     omitSummaryRow?: boolean
     /** Classify the selected child as a subagent instead of an ordinary fork. */
     summaryOrigin?: 'subagent'
+    /** Insert a first-level subagent between the root and selected child. */
+    nestedSubagent?: boolean
     /** A composer block another plugin raised for this session. */
     composerBlock?: { reason: string }
     /** Mutable view ledger used by registration-order regressions. */
@@ -103,16 +106,27 @@ function mount(
   } = {},
 ) {
   const root = sid('root')
+  const parent = sid('parent')
   const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
+  const parentRow = {
+    id: parent, displayTitle: 'Parent', parentId: root, origin: 'subagent' as const,
+    running: false, blank: false, updatedAt: 2,
+  }
   const childRow = {
-    id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
-    running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
+    id: SID, displayTitle: 'Child', parentId: options.nestedSubagent === true ? parent : root,
+    cwd: '/projects/one', running: false, blank: options.summaryBlank ?? false, updatedAt: 3,
     ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
   }
   const listed = options.omitSummaryRow !== true
   const sessions = createSnapshotStore<SessionListState>({
-    ids: listed ? [root, SID] : [root],
-    byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
+    ids: listed
+      ? [root, ...options.nestedSubagent === true ? [parent] : [], SID]
+      : [root],
+    byId: {
+      [root]: rootRow,
+      ...listed && options.nestedSubagent === true && { [parent]: parentRow },
+      ...listed && { [SID]: childRow },
+    },
     current: SID,
     phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
   })
@@ -127,6 +141,7 @@ function mount(
   const stop = vi.fn()
   const open = vi.fn()
   const slotCalls: string[] = []
+  const lineageOwners: ConversationHeaderLineageOwnerProps[] = []
   const viewTabs = options.viewTabs ?? [
     { id: 'chat', label: 'Chat' },
     { id: 'trajectory', label: 'Trajectory' },
@@ -139,12 +154,16 @@ function mount(
   /** Owner share handed to the two composer tool-row seats, per render. */
   const seatOwners: { key: string; owner: unknown }[] = []
   let pickerOwner: unknown
-  const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
+  const renderSlot = ((key: string, owner: object, opts?: { only?: string; fallback?: ReactNode }) => {
     slotCalls.push(key)
     if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
       seatOwners.push({ key, owner })
     }
     if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
+    if (key === 'conversation.session.header.lineage') {
+      lineageOwners.push(owner as ConversationHeaderLineageOwnerProps)
+      return opts?.fallback ?? null
+    }
     if (key === 'conversation.session.header') {
       return (
         <ConversationSessionHeader
@@ -252,7 +271,7 @@ function mount(
   }
   const view = render(<ConversationRoot {...props} />)
   return {
-    view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
+    view, chat, sink, retargetWorkspace, session, slotCalls, lineageOwners, seatOwners, open,
     pickerOwner: () => pickerOwner,
     rerender: () => { view.rerender(<ConversationRoot {...props} />) },
   }
@@ -333,6 +352,22 @@ describe('ConversationRoot resident composer', () => {
     expect(b.open).toHaveBeenCalledWith(sid('root'))
   })
 
+  it('keeps intermediate subagent breadcrumbs at the compact title size', () => {
+    const b = mount(conversationSnapshot(), undefined, undefined, {
+      summaryOrigin: 'subagent',
+      nestedSubagent: true,
+    })
+    expect(b.view.getByRole('button', { name: 'Root' }).className).not.toContain('crumbSubagent')
+    expect(b.view.getByRole('button', { name: 'Parent' }).className).toContain('crumbSubagent')
+    expect(b.view.getByRole('button', { name: 'Child' }).className).toContain('crumbSubagent')
+    expect(b.lineageOwners.slice(-2).map(owner => owner.lineageSessionId)).toEqual([
+      sid('parent'),
+      SID,
+    ])
+    expect(b.lineageOwners.at(-2)?.openTitle).toEqual(expect.any(Function))
+    expect(b.lineageOwners.at(-1)?.openTitle).toBeUndefined()
+  })
+
   it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
     const b = mount(conversationSnapshot())
     const host = b.view.container.querySelector('[data-conversation-scroll]')
@@ -346,6 +381,7 @@ describe('ConversationRoot resident composer', () => {
     expect(host?.contains(header)).toBe(false)
     expect(host?.contains(seat)).toBe(true)
     expect(seat?.contains(textarea)).toBe(true)
+    expect(b.slotCalls).toContain('conversation.session.header.lineage')
     expect(b.slotCalls).toContain('conversation.session.header.actions')
     expect(b.slotCalls).toContain('conversation.session.header.utilities')
   })

+ 2 - 2
packages/client/ui-subagent/README.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 packages/client/ui-subagent/README.md
-README.md: 555278253131dcb6b65d19252fcaec5d0667d93f
-README.zh.md: 64598a43aeb47b3c3cfb198ff33aea62ac1f2a20
+README.md: d4a804a94b0f2cb295fd29c09c7f32385e423495
+README.zh.md: 3cec2109cbb5f85617cf69155c5582ee43496f09

File diff suppressed because it is too large
+ 1 - 1
packages/client/ui-subagent/README.md


File diff suppressed because it is too large
+ 1 - 1
packages/client/ui-subagent/README.zh.md


+ 3 - 1
packages/client/ui-subagent/package.json

@@ -68,8 +68,10 @@
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 61 - 8
packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css → packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css

@@ -1,11 +1,20 @@
 .root {
   position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
 }
 
-.trigger {
+.switcherRoot {
+  min-width: 0;
+  margin-left: 6px;
+}
+
+.trigger,
+.switcherTrigger {
   display: inline-flex;
   align-items: center;
-  gap: 3px;
   min-height: 28px;
   padding: 3px 2px;
   border: 0;
@@ -17,8 +26,39 @@
   cursor: pointer;
 }
 
-.count {
-  margin: 0 5px;
+.trigger {
+  gap: 4px;
+}
+
+.switcherTrigger {
+  gap: 4px;
+  min-width: 0;
+  max-width: 244px;
+  color: var(--dsw-alias-label-primary);
+  font-weight: 500;
+}
+
+.ancestorSwitcherTrigger {
+  color: var(--dsw-alias-label-tertiary);
+  font-weight: 400;
+}
+
+.switcherTitle {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.switcherTrigger svg {
+  flex: none;
+}
+
+.separator {
+  color: var(--dsw-alias-label-caption);
+  font-size: 14px;
+  line-height: 20px;
 }
 
 .activitySlot {
@@ -33,7 +73,18 @@
   color: var(--dsw-alias-label-secondary);
 }
 
-.trigger svg {
+.switcherTrigger:hover,
+.switcherTrigger:focus-visible {
+  color: var(--dsw-alias-label-primary);
+}
+
+.ancestorSwitcherTrigger:hover,
+.ancestorSwitcherTrigger:focus-visible {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.trigger svg,
+.switcherTrigger svg {
   transition: transform 120ms ease;
 }
 
@@ -42,9 +93,7 @@
 }
 
 .menu {
-  position: absolute;
-  top: calc(100% + 5px);
-  left: 0;
+  position: fixed;
   z-index: 100;
   box-sizing: border-box;
   display: flex;
@@ -171,6 +220,10 @@
   font-weight: 400;
 }
 
+.currentLabel {
+  font-weight: 600;
+}
+
 .summary,
 .metrics {
   color: var(--dsw-alias-label-tertiary);

+ 285 - 43
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx → packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx

@@ -1,6 +1,7 @@
 import {
-  useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent,
+  useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent,
 } from 'react'
+import { createPortal } from 'react-dom'
 import {
   indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap,
   type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot,
@@ -13,7 +14,7 @@ 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'
-import css from './SubagentCatalogAction.module.css'
+import css from './SubagentHeaderLineage.module.css'
 
 type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
 type Catalogs = SessionListState['subagentsByParent']
@@ -25,12 +26,13 @@ export interface SubagentCatalogInjected {
   setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
 }
 
-/** Full props for the session-header catalog action. */
-export type SubagentCatalogActionProps =
-  PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
+/** Full props for the session-header lineage renderer. */
+export type SubagentHeaderLineageProps =
+  PropsRuntime<'conversation.session.header.lineage'> & SubagentCatalogInjected & PropsLocale<typeof NS>
 
 interface CatalogRowsProps {
   parentSessionId: SessionId
+  currentSessionId: SessionId | undefined
   catalog: SubagentCatalogSnapshot
   catalogs: Catalogs
   summaries: Readonly<Record<SessionId, SessionSummary>>
@@ -173,6 +175,29 @@ function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
 
 const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const
 
+function SubagentSwitcherIcon() {
+  return (
+    <svg
+      width="16"
+      height="16"
+      viewBox="0 0 20 20"
+      fill="none"
+      aria-hidden="true"
+    >
+      <path
+        d="M5.99951 12.7L8.95546 14.9478C9.40011 15.2859 9.62244 15.455 9.87526 15.488C9.95774 15.4988 10.0413 15.4988 10.1238 15.488C10.3766 15.455 10.5989 15.2859 11.0436 14.9478L13.9995 12.7"
+        stroke="currentColor"
+        strokeWidth="1.5"
+      />
+      <path
+        d="M13.9995 7.7417L11.0436 5.49387C10.5989 5.15574 10.3766 4.98668 10.1238 4.95362C10.0413 4.94283 9.95775 4.94283 9.87527 4.95362C9.62245 4.98668 9.40012 5.15574 8.95547 5.49387L5.99952 7.7417"
+        stroke="currentColor"
+        strokeWidth="1.5"
+      />
+    </svg>
+  )
+}
+
 /** Render the known direct-child shape while its authoritative catalog hydrates. */
 function CatalogLoadingRows({
   parentSessionId,
@@ -210,7 +235,7 @@ function CatalogLoadingRows({
 
 /** Render one catalog level and recurse only through explicitly expanded rows. */
 function CatalogRows({
-  parentSessionId, catalog, catalogs, summaries, expanded, level, now,
+  parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, now,
   openChild, refresh, toggleBranch, closeCatalog, t,
 }: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
   const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
@@ -265,6 +290,7 @@ function CatalogRows({
         }
 
         const childCatalog = catalogs[entry.id]
+        const isCurrent = entry.id === currentSessionId
         const isExpanded = expanded.has(entry.id)
         const knownLeaf = !entry.hasChildren
         const childLoading = childCatalog === undefined
@@ -325,6 +351,7 @@ function CatalogRows({
               role="treeitem"
               tabIndex={0}
               aria-level={level}
+              aria-current={isCurrent || undefined}
               aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')}
               {...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
               className={css.row}
@@ -347,7 +374,7 @@ function CatalogRows({
               <div className={css.clickarea}>
                 <StateDot state={entry.activity === 'running' ? 'ongoing' : 'done'} />
                 <span className={css.content}>
-                  <span className={css.label}>{label}</span>
+                  <span className={`${css.label} ${isCurrent ? css.currentLabel : ''}`}>{label}</span>
                   <span className={css.summary}>{secondary}</span>
                 </span>
                 {metrics !== '' && (
@@ -383,6 +410,7 @@ function CatalogRows({
                   : (
                     <CatalogRows
                       parentSessionId={entry.id}
+                      currentSessionId={currentSessionId}
                       catalog={childCatalog}
                       catalogs={catalogs}
                       summaries={summaries}
@@ -405,29 +433,82 @@ 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 while the catalog is pending or summaries establish descendants.
- */
-export function SubagentCatalogAction({
-  sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
-}: SubagentCatalogActionProps) {
+interface CatalogDropdownSharedProps extends SubagentCatalogInjected {
+  /** Session whose direct catalog roots the tree. */
+  rootSessionId: SessionId
+  /** Whether an ordinary title needs a breadcrumb separator before its count. */
+  separator?: boolean
+  useSessions: SubagentHeaderLineageProps['useSessions']
+  t: TranslateNS<typeof NS>
+}
+
+type CatalogDropdownProps = CatalogDropdownSharedProps & (
+  | {
+    /** Descendant-count control. */
+    variant: 'count'
+    currentSessionId?: never
+    displayTitle?: never
+    openTitle?: never
+  }
+  | {
+    /** Current-title sibling switcher. */
+    variant: 'switcher'
+    /** Selected descendant highlighted in the catalog. */
+    currentSessionId: SessionId
+    /** Visible title included in the switcher's hover target. */
+    displayTitle: string
+    /** Optional ancestor navigation when the combined title is clicked. */
+    openTitle?: () => void
+  }
+)
+
+const MENU_VIEWPORT_MARGIN = 16
+
+/** Place a portaled catalog below its trigger without crossing the viewport edge. */
+function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
+  const rect = trigger.getBoundingClientRect()
+  const width = Math.min(336, window.innerWidth - MENU_VIEWPORT_MARGIN * 2)
+  return {
+    top: rect.bottom + 5,
+    left: Math.min(
+      Math.max(MENU_VIEWPORT_MARGIN, rect.left),
+      window.innerWidth - width - MENU_VIEWPORT_MARGIN,
+    ),
+  }
+}
+
+/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */
+function CatalogDropdown({
+  rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false,
+  useSessions, openChild, refresh, setCatalogOpen, t,
+}: CatalogDropdownProps) {
+  const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
   const catalogs = useSessions(state => state.subagentsByParent)
   const summaries = useSessions(state => state.byId)
-  const catalog = catalogs[sessionId]
+  const catalog = catalogs[rootSessionId]
   const [open, setOpen] = useState(false)
+  const [menuPosition, setMenuPosition] = useState<CSSProperties>()
   const [now, setNow] = useState(() => Date.now())
   const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
   const rootRef = useRef<HTMLDivElement>(null)
   const triggerRef = useRef<HTMLButtonElement>(null)
+  const menuRef = useRef<HTMLDivElement>(null)
+  const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
+  const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
   const observedCatalogs = useRef(new Set<SessionId>())
+  const requestedInitialCatalog = useRef<SessionId>()
   const setCatalogOpenRef = useRef(setCatalogOpen)
   setCatalogOpenRef.current = setCatalogOpen
+  const currentEntry = currentSessionId === undefined
+    ? undefined
+    : catalog?.entries.find(entry => entry.kind === 'child' && entry.id === currentSessionId)
+  const switcherDisplayTitle = currentEntry?.kind === 'child'
+    ? currentEntry.label ?? currentEntry.id
+    : displayTitle
   const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
   const descendants = useMemo(
-    () => indexSubagentDescendants(summaries).get(sessionId) ?? NO_DESCENDANTS,
-    [sessionId, summaries],
+    () => indexSubagentDescendants(summaries).get(rootSessionId) ?? NO_DESCENDANTS,
+    [rootSessionId, summaries],
   )
   // The catalog can arrive before the session-list baseline; never undercount
   // the already-visible direct rows during that short bootstrap window.
@@ -436,7 +517,7 @@ export function SubagentCatalogAction({
   const runningCountKey = descendants.runningCount === 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
+  const summaryBackedLoading = (descendants.count > 0 || variant === 'switcher')
     && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
   const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
     ? {
@@ -447,6 +528,16 @@ export function SubagentCatalogAction({
     }
     : catalog
 
+  useEffect(() => {
+    if (
+      variant !== 'switcher'
+      || catalog !== undefined
+      || requestedInitialCatalog.current === rootSessionId
+    ) return
+    requestedInitialCatalog.current = rootSessionId
+    refresh(rootSessionId)
+  }, [catalog, refresh, rootSessionId, variant])
+
   const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
     if (next) observedCatalogs.current.add(parentSessionId)
     else observedCatalogs.current.delete(parentSessionId)
@@ -461,16 +552,57 @@ export function SubagentCatalogAction({
     setExpanded(new Set())
   }
 
+  const cancelHoverClose = (): void => {
+    if (hoverCloseTimer.current === undefined) return
+    clearTimeout(hoverCloseTimer.current)
+    hoverCloseTimer.current = undefined
+  }
+
+  const cancelHoverOpen = (): void => {
+    if (hoverOpenTimer.current === undefined) return
+    clearTimeout(hoverOpenTimer.current)
+    hoverOpenTimer.current = undefined
+  }
+
   const changeOpen = (next: boolean, restoreFocus = false): void => {
-    setOpen(next)
+    cancelHoverOpen()
+    cancelHoverClose()
     if (next) {
+      const trigger = triggerRef.current
+      /* v8 ignore next -- a queued callback can outlive the trigger */
+      if (trigger === null) return
+      setOpen(true)
+      setMenuPosition(catalogMenuPosition(trigger))
       setNow(Date.now())
-      observeCatalog(sessionId, true)
+      observeCatalog(rootSessionId, true)
+    }
+    else {
+      setOpen(false)
+      setMenuPosition(undefined)
+      closeAllCatalogs()
     }
-    else closeAllCatalogs()
     if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
   }
 
+  const scheduleHoverOpen = (): void => {
+    cancelHoverOpen()
+    cancelHoverClose()
+    if (open) return
+    hoverOpenTimer.current = setTimeout(() => {
+      hoverOpenTimer.current = undefined
+      changeOpen(true)
+    }, 150)
+  }
+
+  const scheduleHoverClose = (): void => {
+    cancelHoverOpen()
+    cancelHoverClose()
+    hoverCloseTimer.current = setTimeout(() => {
+      hoverCloseTimer.current = undefined
+      changeOpen(false)
+    }, 120)
+  }
+
   const closeBranch = (root: SessionId): void => {
     const closing = new Set<SessionId>()
     const visit = (parentSessionId: SessionId): void => {
@@ -498,7 +630,11 @@ export function SubagentCatalogAction({
   useEffect(() => {
     if (!open) return
     const closeOutside = (event: PointerEvent): void => {
-      if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
+      if (
+        event.target instanceof Node
+        && !rootRef.current?.contains(event.target)
+        && !menuRef.current?.contains(event.target)
+      ) {
         changeOpen(false)
       }
     }
@@ -506,6 +642,22 @@ export function SubagentCatalogAction({
     return () => { document.removeEventListener('pointerdown', closeOutside) }
   }, [open])
 
+  useEffect(() => {
+    if (!open) return
+    const placeMenu = (): void => {
+      const trigger = triggerRef.current
+      /* v8 ignore next -- native resize or scroll can outlive the trigger */
+      if (trigger === null) return
+      setMenuPosition(catalogMenuPosition(trigger))
+    }
+    window.addEventListener('resize', placeMenu)
+    document.addEventListener('scroll', placeMenu, true)
+    return () => {
+      window.removeEventListener('resize', placeMenu)
+      document.removeEventListener('scroll', placeMenu, true)
+    }
+  }, [open])
+
   useEffect(() => {
     if (!open || descendants.runningCount === 0) return
     const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
@@ -513,6 +665,8 @@ export function SubagentCatalogAction({
   }, [open, descendants.runningCount])
 
   useEffect(() => () => {
+    cancelHoverOpen()
+    cancelHoverClose()
     for (const parentSessionId of observedCatalogs.current) {
       setCatalogOpenRef.current(parentSessionId, false)
     }
@@ -524,11 +678,15 @@ export function SubagentCatalogAction({
   // selecting any session schedules a refresh whose loading snapshot would
   // otherwise flash the action in and out on childless sessions.
   const visible = presentedCatalog !== undefined
-    && (presentedCatalog.state === 'error'
+    && (variant === 'switcher'
+      || presentedCatalog.state === 'error'
       || presentedCatalog.entries.length > 0
       || descendantCount > 0)
   useEffect(() => {
-    if (visible || !open) return
+    if (visible) return
+    cancelHoverOpen()
+    cancelHoverClose()
+    if (!open) return
     setOpen(false)
     closeAllCatalogs()
   }, [visible, open])
@@ -536,13 +694,13 @@ export function SubagentCatalogAction({
   if (!visible) return null
 
   const focusAt = (index: number): void => {
-    const items = treeItems(rootRef.current)
+    const items = treeItems(menuRef.current)
     if (items.length === 0) return
     items[(index + items.length) % items.length]?.focus()
   }
 
   const navigate = (event: KeyboardEvent<HTMLDivElement>): void => {
-    const items = treeItems(rootRef.current)
+    const items = treeItems(menuRef.current)
     const index = items.indexOf(document.activeElement as HTMLElement)
     if (event.key === 'Escape') {
       event.preventDefault()
@@ -563,18 +721,35 @@ export function SubagentCatalogAction({
   }
 
   return (
-    <div className={css.root} ref={rootRef} onKeyDown={navigate}>
+    <div
+      className={`${css.root} ${variant === 'switcher' ? css.switcherRoot : ''}`}
+      ref={rootRef}
+      onKeyDown={navigate}
+      onMouseEnter={scheduleHoverOpen}
+      onMouseLeave={scheduleHoverClose}
+    >
+      {separator && <span className={css.separator}>/</span>}
       <button
         ref={triggerRef}
         type="button"
-        className={css.trigger}
+        className={variant === 'switcher'
+          ? `${css.switcherTrigger} ${ancestorSwitcher ? css.ancestorSwitcherTrigger : ''}`
+          : css.trigger}
         aria-haspopup="tree"
         aria-expanded={open}
-        aria-label={t(
-          descendants.runningCount > 0 ? runningCountKey : totalCountKey,
-          { count: descendants.runningCount > 0 ? descendants.runningCount : descendantCount },
-        )}
-        onClick={() => { changeOpen(!open) }}
+        aria-label={variant === 'switcher'
+          ? t('switcher.aria', { title: switcherDisplayTitle })
+          : t(
+            descendants.runningCount > 0 ? runningCountKey : totalCountKey,
+            { count: descendants.runningCount > 0 ? descendants.runningCount : descendantCount },
+          )}
+        onClick={openTitle === undefined
+          ? undefined
+          : () => {
+            cancelHoverOpen()
+            if (open) changeOpen(false)
+            openTitle()
+          }}
         onKeyDown={(event) => {
           if (event.key !== 'ArrowDown') return
           event.preventDefault()
@@ -582,16 +757,35 @@ export function SubagentCatalogAction({
           queueMicrotask(() => { focusAt(0) })
         }}
       >
-        <span className={css.activitySlot}>
-          {descendants.runningCount > 0 && <StateDot state="ongoing" />}
-        </span>
-        <span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
-        <IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
+        {variant === 'switcher'
+          ? <span className={css.switcherTitle}>{switcherDisplayTitle}</span>
+          : (
+            <>
+              {descendants.runningCount > 0 && (
+                <span className={css.activitySlot}>
+                  <StateDot state="ongoing" />
+                </span>
+              )}
+              <span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
+            </>
+          )}
+        {variant === 'switcher'
+          ? <SubagentSwitcherIcon />
+          : <IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />}
       </button>
-      {open && (
-        <div className={css.menu} role="tree" aria-label={t('tree.aria')}>
+      {open && createPortal((
+        <div
+          ref={menuRef}
+          className={css.menu}
+          style={menuPosition}
+          role="tree"
+          aria-label={t('tree.aria')}
+          onMouseEnter={cancelHoverClose}
+          onMouseLeave={scheduleHoverClose}
+        >
           <CatalogRows
-            parentSessionId={sessionId}
+            parentSessionId={rootSessionId}
+            currentSessionId={currentSessionId}
             catalog={presentedCatalog}
             catalogs={catalogs}
             summaries={summaries}
@@ -605,7 +799,55 @@ export function SubagentCatalogAction({
             t={t}
           />
         </div>
-      )}
+      ), document.body)}
     </div>
   )
 }
+
+/**
+ * Render one breadcrumb title together with its subagent navigation.
+ * @param props - Breadcrumb title, session standard props, and catalog actions.
+ * @returns An ordinary-title descendant count, or a title-and-chevron sibling switcher.
+ */
+export function SubagentHeaderLineage({
+  lineageSessionId, displayTitle, openTitle,
+  useSessions, openChild, refresh, setCatalogOpen, t,
+}: SubagentHeaderLineageProps) {
+  const parentId = useSessions((state) => {
+    const summary = state.byId[lineageSessionId]
+    return summary?.origin === 'subagent' ? summary.parentId : undefined
+  })
+  const shared = { useSessions, openChild, refresh, setCatalogOpen, t }
+  if (parentId === undefined) {
+    return (
+      <CatalogDropdown
+        key={lineageSessionId}
+        rootSessionId={lineageSessionId}
+        variant="count"
+        separator
+        {...shared}
+      />
+    )
+  }
+  return (
+    <>
+      <CatalogDropdown
+        key={lineageSessionId}
+        rootSessionId={parentId}
+        currentSessionId={lineageSessionId}
+        variant="switcher"
+        displayTitle={displayTitle}
+        {...openTitle === undefined ? {} : { openTitle }}
+        {...shared}
+      />
+      {openTitle === undefined && (
+        <CatalogDropdown
+          key={lineageSessionId}
+          rootSessionId={lineageSessionId}
+          variant="count"
+          {...shared}
+        />
+      )}
+    </>
+  )
+}

+ 6 - 8
packages/client/ui-subagent/src/client/index.ts

@@ -3,7 +3,7 @@ import type {
   ClientContext, SessionId, SubagentAddress,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx'
+import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx'
 import {
   SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
 } from './SubagentReadOnlyComposer.tsx'
@@ -18,8 +18,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 }
 
 export type {
-  SubagentCatalogActionProps, SubagentCatalogInjected,
-} from './SubagentCatalogAction.tsx'
+  SubagentCatalogInjected, SubagentHeaderLineageProps,
+} from './SubagentHeaderLineage.tsx'
 export type {
   SubagentReadOnlyComposerProps, SubagentReadOnlyMatch,
 } from './SubagentReadOnlyComposer.tsx'
@@ -58,14 +58,12 @@ export function apply(ctx: ClientContext): void {
     },
   })
   ctx.slots.inject(
-    'conversation.session.header.actions',
+    'conversation.session.header.lineage',
     () => ctx.slots.register({
-      name: 'conversation.session.header.actions',
-      id: 'subagent-catalog',
-      order: 10,
+      name: 'conversation.session.header.lineage',
       locale: NS,
       inject: catalogActions,
-    }, SubagentCatalogAction),
+    }, SubagentHeaderLineage),
   )
   ctx.slots.inject(
     'conversation.composer',

+ 2 - 0
packages/client/ui-subagent/src/client/locales.ts

@@ -33,6 +33,7 @@ export const zh = {
   'count.total.other': '{count} 个子代理',
   'count.running.one': '{count} 个子代理,正在运行',
   'count.running.other': '{count} 个子代理,正在运行',
+  'switcher.aria': '切换子代理:{title}',
   'tree.aria': '子代理会话',
   'readonly.oneShot.title': '一次性子代理记录',
   'readonly.title': '此子代理暂时只读',
@@ -70,6 +71,7 @@ export const en: Record<SubagentKey, string> = {
   'count.total.other': '{count} subagents',
   'count.running.one': '{count} subagent running',
   'count.running.other': '{count} subagents running',
+  'switcher.aria': 'Switch subagent: {title}',
   'tree.aria': 'Subagent sessions',
   'readonly.oneShot.title': 'One-shot subagent record',
   'readonly.title': 'This subagent is read-only for now',

+ 5 - 5
packages/client/ui-subagent/tests/browser-plugin.client.spec.ts

@@ -9,8 +9,8 @@ import {
 import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import {
-  SubagentCatalogAction, type SubagentCatalogInjected,
-} from '../src/client/SubagentCatalogAction.tsx'
+  SubagentHeaderLineage, type SubagentCatalogInjected,
+} from '../src/client/SubagentHeaderLineage.tsx'
 import {
   SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
 } from '../src/client/SubagentReadOnlyComposer.tsx'
@@ -57,7 +57,7 @@ async function provideSlotFaces(ctx: Context): Promise<void> {
   ctx.slots.register({
     name: 'root',
     children: {
-      'conversation.session.header.actions': { kind: 'list', scope: 'session' },
+      'conversation.session.header.lineage': { kind: 'single', scope: 'session' },
       'conversation.composer': { kind: 'chain', scope: 'session' },
     },
   } as never, () => null)
@@ -94,8 +94,8 @@ describe('apply', () => {
 
   it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
     const { ctx, face } = await fullBench(FAMILY)
-    const catalogEntry = ctx.slots.entries('conversation.session.header.actions')
-      .find(entry => entry.component === SubagentCatalogAction)!
+    const catalogEntry = ctx.slots.entries('conversation.session.header.lineage')
+      .find(entry => entry.component === SubagentHeaderLineage)!
     const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
     const address: SubagentAddress = {
       parentSessionId: sid('parent'),

+ 257 - 47
packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx

@@ -1,13 +1,13 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, within } 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'
 import {
-  SubagentCatalogAction, type SubagentCatalogActionProps,
-} from '../src/client/SubagentCatalogAction.tsx'
+  SubagentHeaderLineage, type SubagentHeaderLineageProps,
+} from '../src/client/SubagentHeaderLineage.tsx'
 import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
 import { zh } from '../src/client/locales.ts'
 
@@ -20,7 +20,7 @@ afterEach(() => {
 const PARENT = 'parent' as SessionId
 const CHILD = 'child' as SessionId
 const GRANDCHILD = 'grandchild' as SessionId
-const t: SubagentCatalogActionProps['t'] = makeTranslate(zh)
+const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh)
 
 function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
   return {
@@ -73,8 +73,10 @@ function props(
     openChild: vi.fn(),
     refresh: vi.fn(),
     setCatalogOpen: vi.fn(),
+    lineageSessionId: PARENT,
+    displayTitle: 'Parent title',
     t,
-  } as unknown as SubagentCatalogActionProps
+  } as unknown as SubagentHeaderLineageProps
 }
 
 function summary(id: SessionId, updatedAt: number): SessionSummary {
@@ -87,7 +89,15 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
   }
 }
 
-describe('SubagentCatalogAction', () => {
+function hoverCatalog(trigger: HTMLElement): void {
+  const root = trigger.parentElement
+  if (root === null) throw new Error('catalog trigger has no root')
+  vi.useFakeTimers()
+  fireEvent.mouseEnter(root)
+  act(() => { vi.advanceTimersByTime(150) })
+}
+
+describe('SubagentHeaderLineage', () => {
   it('aggregates live descendant activity onto the closed trigger', () => {
     const summaries: Record<SessionId, SessionSummary> = {
       [CHILD]: {
@@ -107,23 +117,23 @@ describe('SubagentCatalogAction', () => {
         origin: 'subagent',
       },
     }
-    const view = render(<SubagentCatalogAction {...props(catalog(), {}, summaries)} />)
+    const view = render(<SubagentHeaderLineage {...props(catalog(), {}, summaries)} />)
 
     const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
     expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
 
-    view.rerender(<SubagentCatalogAction {...props(catalog(), {}, {
+    view.rerender(<SubagentHeaderLineage {...props(catalog(), {}, {
       ...summaries,
       [GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
     })} />)
-    expect(screen.getByRole('button', { name: '3 个子代理' })
-      .querySelector('[data-state="ongoing"]')).toBeNull()
+    const inactiveTrigger = screen.getByRole('button', { name: '3 个子代理' })
+    expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull()
   })
 
   it('does not aggregate subagents reached through an ordinary fork', () => {
     const fork = 'fork' as SessionId
     const forkChild = 'fork-child' as SessionId
-    render(<SubagentCatalogAction {...props(catalog(), {}, {
+    render(<SubagentHeaderLineage {...props(catalog(), {}, {
       [CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
       ['child-2' as SessionId]: {
         ...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
@@ -138,9 +148,9 @@ describe('SubagentCatalogAction', () => {
 
   it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
     const input = props(catalog())
-    render(<SubagentCatalogAction {...input} />)
+    render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: /2 个子代理/ })
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
 
     expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
     expect(screen.getAllByRole('treeitem')).toHaveLength(3)
@@ -171,7 +181,7 @@ describe('SubagentCatalogAction', () => {
       },
     })
     const translate = vi.fn(base.t)
-    render(<SubagentCatalogAction {...base} t={translate} />)
+    render(<SubagentHeaderLineage {...base} t={translate} />)
 
     expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
     expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
@@ -184,15 +194,15 @@ describe('SubagentCatalogAction', () => {
         activity: 'running', hasChildren: false,
       }],
     }))
-    render(<SubagentCatalogAction {...input} />)
-    fireEvent.click(screen.getByRole('button', { name: /1 个子代理/ }))
+    render(<SubagentHeaderLineage {...input} />)
+    hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ }))
 
     expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
   })
 
   it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
     const input = props(catalog())
-    render(<SubagentCatalogAction {...input} />)
+    render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: /2 个子代理/ })
     fireEvent.keyDown(trigger, { key: 'ArrowDown' })
     await Promise.resolve()
@@ -209,13 +219,59 @@ describe('SubagentCatalogAction', () => {
     expect(screen.queryByRole('tree')).toBeNull()
     expect(document.activeElement).toBe(trigger)
 
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
     fireEvent.pointerDown(screen.getByRole('tree'))
     expect(screen.getByRole('tree')).toBeTruthy()
     fireEvent.pointerDown(document.body)
     expect(screen.queryByRole('tree')).toBeNull()
   })
 
+  it('opens only on hover and preserves the portaled-menu crossing grace', async () => {
+    vi.useFakeTimers()
+    const view = render(<SubagentHeaderLineage {...props(catalog())} />)
+    const trigger = screen.getByRole('button', { name: /2 个子代理/ })
+
+    fireEvent.click(trigger)
+    expect(screen.queryByRole('tree')).toBeNull()
+
+    fireEvent.mouseEnter(trigger.parentElement!)
+    await vi.advanceTimersByTimeAsync(149)
+    expect(screen.queryByRole('tree')).toBeNull()
+    await vi.advanceTimersByTimeAsync(1)
+    const tree = screen.getByRole('tree')
+    fireEvent.resize(window)
+    fireEvent.mouseLeave(trigger.parentElement!)
+    fireEvent.mouseEnter(tree)
+    await vi.advanceTimersByTimeAsync(120)
+    expect(screen.getByRole('tree')).toBeTruthy()
+
+    fireEvent.mouseLeave(tree)
+    await vi.advanceTimersByTimeAsync(119)
+    expect(screen.getByRole('tree')).toBeTruthy()
+    await vi.advanceTimersByTimeAsync(1)
+    expect(screen.queryByRole('tree')).toBeNull()
+
+    hoverCatalog(trigger)
+    fireEvent.mouseLeave(trigger.parentElement!)
+    view.unmount()
+    await vi.advanceTimersByTimeAsync(120)
+  })
+
+  it('cancels a pending hover when the trigger becomes hidden', async () => {
+    vi.useFakeTimers()
+    const view = render(<SubagentHeaderLineage {...props(catalog())} />)
+    const trigger = screen.getByRole('button', { name: /2 个子代理/ })
+
+    fireEvent.mouseEnter(trigger.parentElement!)
+    await vi.advanceTimersByTimeAsync(149)
+    view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
+    expect(screen.queryByRole('button')).toBeNull()
+
+    await vi.advanceTimersByTimeAsync(1)
+    fireEvent.resize(window)
+    expect(screen.queryByRole('tree')).toBeNull()
+  })
+
   it('covers diagnostic variants, fallback labels, and keyboard row activation', () => {
     const unsupported = 'unsupported' as SessionId
     const unavailable = 'unavailable' as SessionId
@@ -234,11 +290,11 @@ describe('SubagentCatalogAction', () => {
         },
       ],
     }))
-    render(<SubagentCatalogAction {...input} />)
+    render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: /2 个子代理/ })
     fireEvent.keyDown(trigger, { key: 'Tab' })
     expect(screen.queryByRole('tree')).toBeNull()
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
     expect(screen.getByRole('treeitem', { name: /子代理记录版本不受支持/ })).toBeTruthy()
     expect(screen.getByRole('treeitem', { name: /会话记录暂不可用/ })).toBeTruthy()
 
@@ -246,7 +302,7 @@ describe('SubagentCatalogAction', () => {
     expect(input.openChild).toHaveBeenLastCalledWith({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
     })
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
     fireEvent.keyDown(screen.getByRole('treeitem', { name: /unlabeled/ }), { key: ' ' })
     expect(input.openChild).toHaveBeenLastCalledWith({
       parentSessionId: PARENT, childSessionId: unlabeled, mode: 'one-shot',
@@ -322,10 +378,10 @@ describe('SubagentCatalogAction', () => {
       }]
     })) as Record<SessionId, SessionSummary>
     const input = props(catalog({ entries }), {}, summaries)
-    render(<SubagentCatalogAction {...input} />)
+    render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
     expect(within(trigger).getByText('9 个子代理')).toBeTruthy()
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
 
     const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
     const runningMetrics = within(runningRow)
@@ -364,8 +420,8 @@ describe('SubagentCatalogAction', () => {
       [CHILD]: childCatalog,
       [GRANDCHILD]: grandchildCatalog,
     })
-    render(<SubagentCatalogAction {...input} />)
-    fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
+    render(<SubagentHeaderLineage {...input} />)
+    hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
 
     fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
     expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
@@ -392,8 +448,8 @@ describe('SubagentCatalogAction', () => {
       },
     }
     const deferred = props(catalog(), {}, summaries)
-    const view = render(<SubagentCatalogAction {...deferred} />)
-    fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
+    const view = render(<SubagentHeaderLineage {...deferred} />)
+    hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
     fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
 
     expect(deferred.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
@@ -406,7 +462,7 @@ describe('SubagentCatalogAction', () => {
     const loading = props(catalog(), {
       [CHILD]: catalog({ entries: [], state: 'loading' }),
     }, summaries)
-    view.rerender(<SubagentCatalogAction {...loading} />)
+    view.rerender(<SubagentHeaderLineage {...loading} />)
     expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
 
     const ready = props(catalog(), {
@@ -423,7 +479,7 @@ describe('SubagentCatalogAction', () => {
         ],
       }),
     }, summaries)
-    view.rerender(<SubagentCatalogAction {...ready} />)
+    view.rerender(<SubagentHeaderLineage {...ready} />)
     expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
     expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
     expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy()
@@ -439,7 +495,7 @@ describe('SubagentCatalogAction', () => {
         }],
       }),
     })
-    render(<SubagentCatalogAction {...input} />)
+    render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: /2 个子代理/ })
     fireEvent.keyDown(trigger, { key: 'ArrowDown' })
     await Promise.resolve()
@@ -463,8 +519,8 @@ describe('SubagentCatalogAction', () => {
         ],
       }),
     })
-    render(<SubagentCatalogAction {...input} />)
-    fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
+    render(<SubagentHeaderLineage {...input} />)
+    hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
     fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
     fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
     fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
@@ -475,12 +531,12 @@ describe('SubagentCatalogAction', () => {
   })
 
   it('hides an arrived empty catalog and exposes retry for a failed one', () => {
-    const absent = render(<SubagentCatalogAction {...props(undefined)} />)
+    const absent = render(<SubagentHeaderLineage {...props(undefined)} />)
     expect(screen.queryByRole('button')).toBeNull()
     absent.unmount()
 
     const empty = props(catalog({ entries: [] }))
-    const view = render(<SubagentCatalogAction {...empty} />)
+    const view = render(<SubagentHeaderLineage {...empty} />)
     expect(screen.queryByRole('button')).toBeNull()
     view.unmount()
 
@@ -489,8 +545,8 @@ describe('SubagentCatalogAction', () => {
       state: 'error',
       error: { code: 'internal', message: 'index down', details: {} },
     }))
-    render(<SubagentCatalogAction {...failed} />)
-    fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
+    render(<SubagentHeaderLineage {...failed} />)
+    hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ }))
     expect(screen.getByText('index down')).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: /重试/ }))
     expect(failed.refresh).toHaveBeenCalledWith(PARENT)
@@ -507,17 +563,17 @@ describe('SubagentCatalogAction', () => {
       },
     }
     const absent = props(undefined, {}, summaries)
-    const view = render(<SubagentCatalogAction {...absent} />)
+    const view = render(<SubagentHeaderLineage {...absent} />)
 
     const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
     expect(within(trigger).getByText('2 个子代理')).toBeTruthy()
-    fireEvent.click(trigger)
+    hoverCatalog(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} />)
+    view.rerender(<SubagentHeaderLineage {...staleEmpty} />)
     expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
     expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
     expect(staleEmpty.openChild).not.toHaveBeenCalled()
@@ -527,14 +583,14 @@ describe('SubagentCatalogAction', () => {
     // Selecting any session schedules a catalog refresh; a loading snapshot
     // with no other evidence of children must not flash the action in.
     const loading = props(catalog({ entries: [], state: 'loading' }))
-    const view = render(<SubagentCatalogAction {...loading} />)
+    const view = render(<SubagentHeaderLineage {...loading} />)
     expect(screen.queryByRole('button')).toBeNull()
     view.unmount()
 
     const failed = props(catalog({ entries: [], state: 'error', error: null }))
-    render(<SubagentCatalogAction {...failed} />)
+    render(<SubagentHeaderLineage {...failed} />)
     const trigger = screen.getByRole('button', { name: /0 个子代理/ })
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
     expect(screen.getByText('无法加载子代理')).toBeTruthy()
     fireEvent.keyDown(trigger, { key: 'ArrowDown' })
     await Promise.resolve()
@@ -544,9 +600,9 @@ describe('SubagentCatalogAction', () => {
 
   it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
     const input = props(catalog())
-    const view = render(<SubagentCatalogAction {...input} />)
+    const view = render(<SubagentHeaderLineage {...input} />)
     const trigger = screen.getByRole('button', { name: /2 个子代理/ })
-    fireEvent.click(trigger)
+    hoverCatalog(trigger)
     fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
     expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
     fireEvent.keyDown(trigger, { key: 'ArrowDown' })
@@ -563,16 +619,170 @@ describe('SubagentCatalogAction', () => {
         }],
       }),
     })
-    const view = render(<SubagentCatalogAction {...populated} />)
-    fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
+    const view = render(<SubagentHeaderLineage {...populated} />)
+    hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
     fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
 
     const empty = props(catalog({ entries: [] }))
-    view.rerender(<SubagentCatalogAction {...empty} />)
+    view.rerender(<SubagentHeaderLineage {...empty} />)
     expect(screen.queryByRole('button')).toBeNull()
     expect(empty.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
     expect(empty.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
   })
+
+  it('places a separator before a visible ordinary-session descendant count', () => {
+    const view = render(<SubagentHeaderLineage {...props(catalog())} />)
+
+    expect(screen.getByText('/')).toBeTruthy()
+    expect(screen.getByRole('button', { name: /2 个子代理/ })).toBeTruthy()
+
+    view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
+    expect(screen.queryByText('/')).toBeNull()
+  })
+
+  it('combines the current subagent title and chevron into the parent-catalog trigger', () => {
+    const sibling = 'child-2' as SessionId
+    const input = {
+      ...props(catalog(), {}, {
+        [CHILD]: {
+          ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+        },
+        [sibling]: {
+          ...summary(sibling, 1), parentId: PARENT, origin: 'subagent' as const,
+        },
+      }),
+      sessionId: CHILD,
+      lineageSessionId: CHILD,
+      displayTitle: 'worker',
+    }
+    render(<SubagentHeaderLineage {...input} />)
+
+    const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
+    expect(switcher.className).toContain('switcherTrigger')
+    expect(within(switcher).getByText('worker')).toBeTruthy()
+    expect(switcher.querySelector('svg')).not.toBeNull()
+
+    const switcherIcon = switcher.querySelector('svg')
+    expect(switcherIcon?.getAttribute('width')).toBe('16')
+    expect(switcherIcon?.getAttribute('height')).toBe('16')
+
+    hoverCatalog(switcher)
+    expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
+    const current = screen.getByRole('treeitem', { name: /worker/ })
+    expect(current.getAttribute('aria-current')).toBe('true')
+    expect(within(current).getByText('worker').className).toContain('currentLabel')
+    fireEvent.click(screen.getByRole('treeitem', { name: /reviewer/ }))
+    expect(input.openChild).toHaveBeenCalledWith({
+      parentSessionId: PARENT, childSessionId: sibling, mode: 'one-shot',
+    })
+  })
+
+  it('falls back to the catalog session id when the selected row has no label', () => {
+    const input = {
+      ...props(catalog({ entries: [{
+        kind: 'child', id: CHILD, mode: 'one-shot',
+        activity: 'inactive', hasChildren: false,
+      }] }), {}, {
+        [CHILD]: {
+          ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+        },
+      }),
+      sessionId: CHILD,
+      lineageSessionId: CHILD,
+      displayTitle: 'summary title',
+    }
+    render(<SubagentHeaderLineage {...input} />)
+
+    expect(screen.getByRole('button', { name: `切换子代理:${CHILD}` })).toBeTruthy()
+  })
+
+  it('keeps an ancestor switcher muted and omits its descendant count', () => {
+    const input = {
+      ...props(catalog(), {
+        [CHILD]: catalog({ entries: [{
+          kind: 'child', id: GRANDCHILD, mode: 'continuable',
+          label: 'indexer', activity: 'inactive', hasChildren: false,
+        }] }),
+      }, {
+        [CHILD]: {
+          ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+        },
+        [GRANDCHILD]: {
+          ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
+        },
+      }),
+      lineageSessionId: CHILD,
+      displayTitle: '正在扫描项目文件',
+      openTitle: vi.fn(),
+    }
+    render(<SubagentHeaderLineage {...input} />)
+
+    const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
+    expect(switcher.className).toContain('ancestorSwitcherTrigger')
+    expect(screen.queryByRole('button', { name: /1 个子代理/ })).toBeNull()
+    vi.useFakeTimers()
+    fireEvent.mouseEnter(switcher.parentElement!)
+    fireEvent.click(switcher)
+    expect(input.openTitle).toHaveBeenCalledOnce()
+    act(() => { vi.advanceTimersByTime(150) })
+    expect(screen.queryByRole('tree')).toBeNull()
+
+    hoverCatalog(switcher)
+    expect(screen.getByRole('tree')).toBeTruthy()
+    fireEvent.click(switcher)
+    expect(input.openTitle).toHaveBeenCalledTimes(2)
+    expect(screen.queryByRole('tree')).toBeNull()
+  })
+
+  it.each([
+    ['ancestor', vi.fn()],
+    ['current', undefined],
+  ] as const)('refreshes an absent %s switcher catalog without waiting for hover', (_kind, openTitle) => {
+    const input = {
+      ...props(undefined, {}, {
+        [CHILD]: {
+          ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+          displayTitle: '正在扫描项目文件',
+        },
+      }),
+      lineageSessionId: CHILD,
+      displayTitle: '正在扫描项目文件',
+      ...(openTitle === undefined ? {} : { openTitle }),
+    }
+    render(<SubagentHeaderLineage {...input} />)
+
+    expect(input.refresh).toHaveBeenCalledWith(PARENT)
+  })
+
+  it('keeps a nested title switcher scoped to its direct-parent catalog', () => {
+    const input = {
+      ...props(catalog(), {
+        [CHILD]: catalog({ entries: [{
+          kind: 'child', id: GRANDCHILD, mode: 'continuable',
+          label: 'indexer', activity: 'inactive', hasChildren: false,
+        }] }),
+      }, {
+        [CHILD]: {
+          ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+        },
+        [GRANDCHILD]: {
+          ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
+        },
+      }),
+      sessionId: GRANDCHILD,
+      lineageSessionId: GRANDCHILD,
+      displayTitle: 'indexer',
+    }
+    render(<SubagentHeaderLineage {...input} />)
+
+    hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' }))
+
+    expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
+    const current = screen.getByRole('treeitem', { name: /indexer/ })
+    expect(current.getAttribute('aria-current')).toBe('true')
+    expect(within(current).getByText('indexer').className).toContain('currentLabel')
+    expect(screen.queryByRole('treeitem', { name: /reviewer/ })).toBeNull()
+  })
 })
 
 describe('SubagentReadOnlyComposer', () => {

+ 54 - 22
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -160,7 +160,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:148',
   },
   {
     key: 'conversation.chat.commandview',
@@ -199,7 +199,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
   },
   {
     key: 'conversation.chat.node',
@@ -255,7 +255,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:115',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -295,7 +295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:130',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:140',
   },
   {
     key: 'conversation.composer',
@@ -338,7 +338,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
   },
   {
     key: 'conversation.composer.bar',
@@ -371,7 +371,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:235',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:245',
   },
   {
     key: 'conversation.composer.dock',
@@ -424,7 +424,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
   },
   {
     key: 'conversation.details.tool',
@@ -455,7 +455,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n      { name: \'conversation.details.tool\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -481,7 +481,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -507,7 +507,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183',
   },
   {
     key: 'conversation.hero.workspace',
@@ -535,7 +535,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -596,7 +596,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:247',
   },
   {
     key: 'conversation.input.dock',
@@ -651,7 +651,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205',
   },
   {
     key: 'conversation.input.left',
@@ -702,7 +702,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223',
   },
   {
     key: 'conversation.input.model',
@@ -733,7 +733,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:271',
   },
   {
     key: 'conversation.input.overlay',
@@ -813,7 +813,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:251',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261',
   },
   {
     key: 'conversation.input.right',
@@ -864,7 +864,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:221',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231',
   },
   {
     key: 'conversation.message.images',
@@ -898,7 +898,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:114',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124',
   },
   {
     key: 'conversation.session',
@@ -1004,11 +1004,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
       'client-ui-jobs JobListAction id \'job-list\'',
-      'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100',
+  },
+  {
+    key: 'conversation.session.header.lineage',
+    kind: 'single',
+    scope: 'session',
+    summary: 'One breadcrumb title and its lineage controls.',
+    doc: 'One breadcrumb title and its lineage controls. The render site keeps\nthe ordinary title as fallback; an occupant receives plain title data\nand may replace a subagent title with one combined navigation control.',
+    registerOptions: [],
+    ownerProps: [
+      '/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n  /** Session represented by this breadcrumb title. */\n  lineageSessionId: SessionId\n  /** Display title available to a renderer that combines the title with a control. */\n  displayTitle: string\n  /** Navigate to an ancestor title when its combined control is clicked. */\n  openTitle?: () => void\n}',
+    ],
+    ownerPropsReferences: [
+      'SessionId',
+    ],
+    standardProps: [
+      'useSessions: SnapshotSelectorHook<SessionListState>',
+      'useWorkspaces: SnapshotSelectorHook<import(\'./workspaces/service.ts\').WorkspaceListState>',
+      'useSession: SnapshotSelectorHook<ConversationSnapshot>',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-subagent SubagentHeaderLineage',
+    ],
+    replaceRisk: 'shadows-shipped-ui',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:85',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1058,7 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105',
   },
   {
     key: 'conversation.view',
@@ -1109,7 +1141,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:103',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:113',
   },
   {
     key: 'details',

+ 6 - 0
pnpm-lock.yaml

@@ -2891,9 +2891,15 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
       react:
         specifier: ^18.2.0
         version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
 
   packages/client/ui-theme:
     dependencies:

Some files were not shown because too many files changed in this diff