Просмотр исходного кода

Merge pull request #4417 from deepseek-harness/feat/subagent-sidebar-chat

feat(client): open subagent chats in sidebar
imccyu 1 неделя назад
Родитель
Сommit
9e3a30f85a
78 измененных файлов с 1471 добавлено и 454 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  2. 2 0
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  3. 2 0
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  4. 48 1
      apps/web/tests/subagent-conversation.e2e.ts
  5. 2 2
      docs/module-graph.i18n.yaml
  6. 14 13
      docs/module-graph.md
  7. 14 13
      docs/module-graph.zh.md
  8. 2 2
      docs/subsystems/client-resources.i18n.yaml
  9. 2 1
      docs/subsystems/client-resources.md
  10. 2 1
      docs/subsystems/client-resources.zh.md
  11. 2 2
      docs/subsystems/session-reference.i18n.yaml
  12. 7 6
      docs/subsystems/session-reference.md
  13. 7 6
      docs/subsystems/session-reference.zh.md
  14. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  15. 2 0
      docs/subsystems/sidebar-right.md
  16. 2 0
      docs/subsystems/sidebar-right.zh.md
  17. 10 0
      packages/api/session-controller/src/client/sessions/manager.ts
  18. 12 3
      packages/api/session-controller/src/client/sessions/service.ts
  19. 18 4
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  20. 1 0
      packages/client/ui-chat/package.json
  21. 2 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  22. 2 0
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  23. 3 0
      packages/client/ui-chat/tsconfig.json
  24. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  25. 2 2
      packages/client/ui-conversation/README.md
  26. 2 2
      packages/client/ui-conversation/README.zh.md
  27. 4 1
      packages/client/ui-conversation/src/client/apply.ts
  28. 20 6
      packages/client/ui-conversation/src/client/contract/slots.ts
  29. 1 0
      packages/client/ui-conversation/src/client/index.ts
  30. 20 131
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  31. 5 93
      packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
  32. 27 30
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  33. 177 0
      packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx
  34. 4 3
      packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx
  35. 16 10
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  36. 2 2
      packages/client/ui-reference/README.i18n.yaml
  37. 1 1
      packages/client/ui-reference/README.md
  38. 1 1
      packages/client/ui-reference/README.zh.md
  39. 23 10
      packages/client/ui-reference/src/client/index.ts
  40. 2 0
      packages/client/ui-reference/src/client/locales.ts
  41. 63 1
      packages/client/ui-reference/tests/browser-plugin.client.spec.ts
  42. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  43. 2 0
      packages/client/ui-sidebar-right/README.md
  44. 2 0
      packages/client/ui-sidebar-right/README.zh.md
  45. 2 0
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  46. 18 1
      packages/client/ui-sidebar-right/src/client/service.ts
  47. 26 12
      packages/client/ui-sidebar-right/src/client/stores.ts
  48. 1 0
      packages/client/ui-sidebar-right/src/client/tab-domain.ts
  49. 49 0
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  50. 6 3
      packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts
  51. 2 2
      packages/client/ui-subagent/README.i18n.yaml
  52. 3 1
      packages/client/ui-subagent/README.md
  53. 3 1
      packages/client/ui-subagent/README.zh.md
  54. 6 0
      packages/client/ui-subagent/package.json
  55. 23 0
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
  56. 28 5
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
  57. 14 2
      packages/client/ui-subagent/src/client/index.ts
  58. 4 0
      packages/client/ui-subagent/src/client/locales.ts
  59. 7 0
      packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css
  60. 204 0
      packages/client/ui-subagent/src/client/sidebar-chat/index.tsx
  61. 14 1
      packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
  62. 9 0
      packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
  63. 237 0
      packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx
  64. 9 0
      packages/client/ui-subagent/tsconfig.json
  65. 2 2
      packages/context/session-reference/README.i18n.yaml
  66. 1 1
      packages/context/session-reference/README.md
  67. 1 1
      packages/context/session-reference/README.zh.md
  68. 2 0
      packages/context/session-reference/package.json
  69. 33 24
      packages/context/session-reference/src/index.ts
  70. 2 0
      packages/context/session-reference/src/types.ts
  71. 97 15
      packages/context/session-reference/tests/session-reference.spec.ts
  72. 3 0
      packages/context/session-reference/tsconfig.json
  73. 59 21
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  74. 4 4
      packages/extensions/tool-cordis/src/api-catalog.ts
  75. 15 0
      pnpm-lock.yaml
  76. 4 1
      snapshots/web/subagent-conversation/branchless.expected.md
  77. 35 0
      snapshots/web/subagent-conversation/sidebar-chat.expected.md
  78. 10 2
      snapshots/web/subagent-conversation/tree.expected.md

+ 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: d0713731de86df639a426ee56c39857c50af6bc7
-2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc
+2026-07-27-web-subagent-conversations.md: fd2ff912a83f2535e33472e2b5755b9d059ba674
+2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e

+ 2 - 0
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -16,6 +16,8 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable
 
 The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation.
 
+The same header row can open a child in the right Sidebar as `dsh-resource://chat/session/<childSessionId>?parent=<parentSessionId>&mode=<mode>`. The open prefers a separate pane and falls back to the current pane when no split is available. The Sidebar tab renders the shared Conversation Component Factory with its width controls omitted, so main and embedded conversations share one assembly without sharing layout chrome.
+
 Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation.
 
 The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.

+ 2 - 0
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -16,6 +16,8 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su
 
 Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。
 
+同一个页头行还可以把 child 作为 `dsh-resource://chat/session/<childSessionId>?parent=<parentSessionId>&mode=<mode>` 在右侧 Sidebar 打开。打开时优先使用独立分栏;无法分栏时回退到当前分栏。Sidebar tab 使用共享 Conversation Component Factory 渲染并省略宽度控制,因此主对话与嵌入式对话共用一套组装,而不共享布局界面。
+
 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。
 
 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。

+ 48 - 1
apps/web/tests/subagent-conversation.e2e.ts

@@ -17,7 +17,7 @@ import {
   launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
@@ -28,12 +28,14 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con
 const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
 const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
 const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url))
+const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url))
 const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url))
 const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const LABEL = 'event-sourcing researcher'
 const ONE_SHOT_LABEL = 'event-sourcing reviewer'
 const NESTED_LABEL = 'example editor'
+const CHILD_TITLE = 'Explain event sourcing in one'
 const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
 const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
 /** The grandchild's own first message; its arrival is what says its history finished loading. */
@@ -97,6 +99,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
   let childId: SessionId
   let oneShotId: SessionId
   let grandchildId: SessionId
+  let liveReferenceOptions: string[]
   let tripwire: ReturnType<typeof watchConsole>
   const apiCalls: string[] = []
 
@@ -145,6 +148,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     })
     childId = started.childId
     await waitForAgentToSettle(scaffold, childId)
+    const liveInput = page.locator('[data-composer-input][contenteditable="true"]').first()
+    const liveMenu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    await writeComposerDraft(page, liveInput, '@')
+    await liveMenu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 })
+    liveReferenceOptions = await liveMenu.getByRole('option').allTextContents()
+    await page.keyboard.press('Escape')
+    await writeComposerDraft(page, liveInput, '')
     oneShotId = sessionId('recorded-one-shot')
     const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
     const oneShotAt = Date.now() - oneShotDurationMs
@@ -295,6 +305,20 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
   })
 
+  it('lists direct subagents by label in the reference menu', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-reference-title'))
+    expect(liveReferenceOptions.some(text => text.includes(LABEL))).toBe(true)
+    expect(liveReferenceOptions.some(text => text.includes(CHILD_TITLE))).toBe(false)
+    const input = page.locator('[data-composer-input][contenteditable="true"]').first()
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    await writeComposerDraft(page, input, '@')
+    await menu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 })
+    await menu.getByRole('option', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
+    expect(await menu.getByRole('option', { name: new RegExp(CHILD_TITLE) }).count()).toBe(0)
+    await page.keyboard.press('Escape')
+    await writeComposerDraft(page, input, '')
+  })
+
   it('keeps known descendants reachable across a stale empty catalog response', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
     const pattern = '**/api/subagents/list'
@@ -380,6 +404,29 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
   })
 
+  it('opens child history in the right Sidebar and releases it when closed', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat'))
+    await page.getByRole('button', { name: '3 subagents' }).hover()
+    await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click()
+    const sidebarChat = page.locator('[data-sidebar-chat]')
+    await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 })
+    await compareOrRefreshGolden(
+      SIDEBAR_CHAT_EXPECTED,
+      await captureStableAria(page, '[data-sidebar-chat]', scaffold.workspaceCwd),
+      MODE,
+    )
+    await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click()
+    await sidebarChat.waitFor({ state: 'detached' })
+
+    await page.getByRole('button', { name: '3 subagents' }).hover()
+    await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click()
+    await page.locator('[data-sidebar-chat]').getByText(
+      'One-shot tasks do not accept follow-ups; review the full execution record here.',
+    ).waitFor({ timeout: 15_000 })
+    await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click()
+    await page.locator('[data-sidebar-chat]').waitFor({ state: 'detached' })
+  })
+
   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' }).hover()

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870
-module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03
+module-graph.md: a886619fa5cba78b2c243c525b8f2a173c1e8038
+module-graph.zh.md: 9c40a086df916cd638599176a95c649d6a2adc0d

+ 14 - 13
docs/module-graph.md

@@ -1116,18 +1116,6 @@ flowchart TD
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
   pkg_compaction_tool_result_pruner --> pkg_token_meter
-  pkg_session_reference --> pkg_agent
-  pkg_session_reference --> pkg_compaction
-  pkg_session_reference --> pkg_llm
-  pkg_session_reference --> pkg_output_retention
-  pkg_session_reference --> pkg_session
-  pkg_session_reference --> pkg_session_projection
-  pkg_session_reference --> pkg_session_projection_cache
-  pkg_session_reference --> pkg_session_query
-  pkg_session_reference --> pkg_session_title
-  pkg_session_reference --> pkg_spill
-  pkg_session_reference --> pkg_system_prompt
-  pkg_session_reference --> pkg_typert_protocol
   pkg_webhook_github --> pkg_credentials
   pkg_webhook_github --> pkg_host_webserver
   pkg_webhook_github --> pkg_session
@@ -1211,6 +1199,19 @@ flowchart TD
   pkg_compaction_basic --> pkg_llm
   pkg_compaction_basic --> pkg_session
   pkg_compaction_basic --> pkg_token_meter
+  pkg_session_reference --> pkg_agent
+  pkg_session_reference --> pkg_compaction
+  pkg_session_reference --> pkg_llm
+  pkg_session_reference --> pkg_output_retention
+  pkg_session_reference --> pkg_session
+  pkg_session_reference --> pkg_session_projection
+  pkg_session_reference --> pkg_session_projection_cache
+  pkg_session_reference --> pkg_session_query
+  pkg_session_reference --> pkg_session_title
+  pkg_session_reference --> pkg_spill
+  pkg_session_reference --> pkg_subagent
+  pkg_session_reference --> pkg_system_prompt
+  pkg_session_reference --> pkg_typert_protocol
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_invariants
   pkg_experimental_agent_team --> pkg_llm
@@ -1568,7 +1569,6 @@ flowchart TD
 | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
-| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1579,6 +1579,7 @@ flowchart TD
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 14 - 13
docs/module-graph.zh.md

@@ -1118,18 +1118,6 @@ flowchart TD
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
   pkg_compaction_tool_result_pruner --> pkg_token_meter
-  pkg_session_reference --> pkg_agent
-  pkg_session_reference --> pkg_compaction
-  pkg_session_reference --> pkg_llm
-  pkg_session_reference --> pkg_output_retention
-  pkg_session_reference --> pkg_session
-  pkg_session_reference --> pkg_session_projection
-  pkg_session_reference --> pkg_session_projection_cache
-  pkg_session_reference --> pkg_session_query
-  pkg_session_reference --> pkg_session_title
-  pkg_session_reference --> pkg_spill
-  pkg_session_reference --> pkg_system_prompt
-  pkg_session_reference --> pkg_typert_protocol
   pkg_webhook_github --> pkg_credentials
   pkg_webhook_github --> pkg_host_webserver
   pkg_webhook_github --> pkg_session
@@ -1213,6 +1201,19 @@ flowchart TD
   pkg_compaction_basic --> pkg_llm
   pkg_compaction_basic --> pkg_session
   pkg_compaction_basic --> pkg_token_meter
+  pkg_session_reference --> pkg_agent
+  pkg_session_reference --> pkg_compaction
+  pkg_session_reference --> pkg_llm
+  pkg_session_reference --> pkg_output_retention
+  pkg_session_reference --> pkg_session
+  pkg_session_reference --> pkg_session_projection
+  pkg_session_reference --> pkg_session_projection_cache
+  pkg_session_reference --> pkg_session_query
+  pkg_session_reference --> pkg_session_title
+  pkg_session_reference --> pkg_spill
+  pkg_session_reference --> pkg_subagent
+  pkg_session_reference --> pkg_system_prompt
+  pkg_session_reference --> pkg_typert_protocol
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_invariants
   pkg_experimental_agent_team --> pkg_llm
@@ -1570,7 +1571,6 @@ flowchart TD
 | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
-| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1581,6 +1581,7 @@ flowchart TD
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/client-resources.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 docs/subsystems/client-resources.md
-client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0
-client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e
+client-resources.md: 09acaf72eb5ee6f6003d9b275afa8a73f0822771
+client-resources.zh.md: 4a0e30d2d885d143b1bc59ca58f59fd8d1f3e9b7

+ 2 - 1
docs/subsystems/client-resources.md

@@ -15,6 +15,7 @@ A resource address is a `dsh-resource://<type>/…` URL. The host names the prot
 | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered |
 | `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed |
 | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits |
+| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | an addressed subagent Conversation whose value owns a `SessionReference` until the resource closes |
 | `sidebar://guide` | — | `none`: a navigation address |
 | `/home/me/notes.md` | — | `none`: not a URL |
 
@@ -84,7 +85,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f
 
 A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
 
-Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
+Most streams carry metadata rather than content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). The `subagentchat` provider retains the addressed Session and yields its `SessionReference`, then releases that reference when the resource signal aborts.
 
 ## Limits
 

+ 2 - 1
docs/subsystems/client-resources.zh.md

@@ -15,6 +15,7 @@
 | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) |
 | `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session |
 | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 |
+| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | 已寻址的 subagent Conversation;其值持有一个 `SessionReference`,直到资源关闭 |
 | `sidebar://guide` | — | `none`:导航地址 |
 | `/home/me/notes.md` | — | `none`:不是 URL |
 
@@ -84,7 +85,7 @@ export function FileHeader({ useTabInfo, useResource, t }: Props) {
 
 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
 
-流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。
+多数流只推元数据而不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。`subagentchat` 提供方保留已寻址 Session 并产出其 `SessionReference`,在资源 signal 中止时释放该 reference。
 
 ## 限制
 

+ 2 - 2
docs/subsystems/session-reference.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 docs/subsystems/session-reference.md
-session-reference.md: 1f44e1b96e31f446ed7f3fe28b625db0958da231
-session-reference.zh.md: 4f5cccf32b0f987f47923fb104e4c66396f89ba9
+session-reference.md: a2567a6cc3f6dde9a32db770a3a5f65c35ada219
+session-reference.zh.md: 43cd567aaa94cf4f54cd286c1dfc29d6a913b3e4

+ 7 - 6
docs/subsystems/session-reference.md

@@ -34,7 +34,7 @@ interface SessionReferenceInput {
 }
 ```
 
-`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, and filtering searches that label alongside session id and cwd, never transcript text.
+`SessionReferenceCandidate` is host-facing discovery output. Its `label` uses the latest Session title when present, while its optional display text prefers a subagent's durable creation label; filtering searches both alongside Session id and cwd, never transcript text. The Remote candidate's canonical mention uses the display text when present.
 
 ```ts type-equiv
 /** One host-facing candidate from exact session metadata. */
@@ -43,6 +43,8 @@ interface SessionReferenceCandidate {
   sessionId: SessionId
   /** Latest log-backed title, falling back to the opaque session id. */
   label: string
+  /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */
+  displayTitle?: string
   /** Source session working directory, when recorded. */
   cwd?: string
   /**
@@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context.
 /**
  * List reference candidates, ranked by working-directory affinity.
  *
- * Discovery runs at keystroke rate, so a title only ever comes from a
- * projection read: see {@link SessionReferenceResolver.projectedTitle} for
- * which sessions can answer one and which fall back to their id.
+ * Discovery runs at keystroke rate, so titles and subagent labels only ever
+ * come from projection reads; sessions without either fall back to their id.
  * @param agent - target agent; self is excluded and its cwd drives ranking.
- * @param query - optional case-insensitive session-id/cwd/title substring.
+ * @param query - optional case-insensitive session-id/cwd/title/display-title substring.
  * @param limit - optional positive result cap.
  * @param signal - optional cancellation boundary for host autocomplete teardown.
- * @returns candidates labeled by latest title or, when absent, session id.
+ * @returns candidates with canonical mention labels and presentation titles.
  */
 async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>
 

+ 7 - 6
docs/subsystems/session-reference.zh.md

@@ -34,7 +34,7 @@ interface SessionReferenceInput {
 }
 ```
 
-`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。
+`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 `label` 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。Remote 候选在 `displayTitle` 存在时用它标记规范 mention。
 
 ```ts type-equiv
 /** One host-facing candidate from exact session metadata. */
@@ -43,6 +43,8 @@ interface SessionReferenceCandidate {
   sessionId: SessionId
   /** Latest log-backed title, falling back to the opaque session id. */
   label: string
+  /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */
+  displayTitle?: string
   /** Source session working directory, when recorded. */
   cwd?: string
   /**
@@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context.
 /**
  * List reference candidates, ranked by working-directory affinity.
  *
- * Discovery runs at keystroke rate, so a title only ever comes from a
- * projection read: see {@link SessionReferenceResolver.projectedTitle} for
- * which sessions can answer one and which fall back to their id.
+ * Discovery runs at keystroke rate, so titles and subagent labels only ever
+ * come from projection reads; sessions without either fall back to their id.
  * @param agent - target agent; self is excluded and its cwd drives ranking.
- * @param query - optional case-insensitive session-id/cwd/title substring.
+ * @param query - optional case-insensitive session-id/cwd/title/display-title substring.
  * @param limit - optional positive result cap.
  * @param signal - optional cancellation boundary for host autocomplete teardown.
- * @returns candidates labeled by latest title or, when absent, session id.
+ * @returns candidates with canonical mention labels and presentation titles.
  */
 async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>
 

+ 2 - 2
docs/subsystems/sidebar-right.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 docs/subsystems/sidebar-right.md
-sidebar-right.md: 470b72118ee680f84489d13668e74c0eede652b0
-sidebar-right.zh.md: 62481c4024e6647a7e620a9c8e72ed6d0c969a60
+sidebar-right.md: a6478e10f19bc11f2a8bfa89d6213a85ffd76ffc
+sidebar-right.zh.md: af23b93945ce714cf4f0485752d8e07f155055c2

+ 2 - 0
docs/subsystems/sidebar-right.md

@@ -79,6 +79,7 @@ Two opens are the navigation controller, and every way into the column calls one
 | `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). |
 | `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. |
 | `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. |
+| `preferNewPane` | Prefer a new docked pane under the normal pane budget and room rule; fall back to the target pane when it cannot split. Ignored with `replaceTab`. |
 | `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. |
 | `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab<K>` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. |
 
@@ -136,6 +137,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
 - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
 - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
 - **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)).
+- **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)).
 
 <a id="not-built"></a>
 ## Not built

+ 2 - 0
docs/subsystems/sidebar-right.zh.md

@@ -79,6 +79,7 @@ export function apply(ctx: Context): void {
 | `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 |
 | `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 |
 | `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 |
+| `preferNewPane` | 在普通格数预算与空间规则下优先新建停靠格;不能分栏时回退到目标格。与 `replaceTab` 一起使用时忽略。 |
 | `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 |
 | `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab<K>` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 |
 
@@ -136,6 +137,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
 - **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。
+- **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。
 
 <a id="not-built"></a>
 ## 不做

+ 10 - 0
packages/api/session-controller/src/client/sessions/manager.ts

@@ -2,6 +2,7 @@
 
 import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
 import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
+import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type {
   SessionControlBaseline,
@@ -584,6 +585,15 @@ export class SessionManager {
     return this.listSnapshotCache
   }
 
+  /**
+   * Read cached projection values for a Session that may exist only in a loaded subagent catalog.
+   * @param sessionId - Session whose control or history baseline supplied projections.
+   * @returns current values, or undefined before any projection store exists.
+   */
+  projectionValues(sessionId: SessionId): Readonly<Partial<SessionProjectionMap>> | undefined {
+    return this.projectionStores.get(sessionId)?.values()
+  }
+
   // ---- Live control and Host-event sinks ----
 
   /**

+ 12 - 3
packages/api/session-controller/src/client/sessions/service.ts

@@ -655,16 +655,25 @@ export class ClientSessions implements ISessions {
       for (const child of catalog.entries) {
         if (child.kind !== 'child') continue
         const childId = child.id
-        const displayTitle = child.label ?? childId
         const summary = byId[childId]
+        const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId)
+        const projectedTitle = projectionValues?.title
+        const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined
+        const displayTitle = title ?? child.label ?? childId
         if (summary === undefined) {
           byId[childId] = {
             id: childId, displayTitle, parentId: parentId as SessionId,
             origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
             retainedBy: this.retentionSnapshot(childId).retainedBy,
+            ...(projectionValues === undefined ? {} : { projectionValues }),
+            ...(title === undefined ? {} : { title }),
+          }
+        } else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
+          byId[childId] = {
+            ...summary,
+            displayTitle,
+            ...(projectionValues === undefined ? {} : { projectionValues }),
           }
-        } else if (summary.displayTitle !== displayTitle) {
-          byId[childId] = { ...summary, displayTitle }
         }
       }
     }

+ 18 - 4
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -703,6 +703,9 @@ describe('borrow-only bindings', () => {
 describe('catalog-addressed navigation', () => {
   it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => {
     const b = bench()
+    b.svc.handleControlFrame({
+      type: 'projection', sessionId: sid('one-shot'), key: 'title', value: 'Investigate startup', seq: 2,
+    })
     b.mock.remote.subagents.list.mockResolvedValue(ok({
       entries: [
         { kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false },
@@ -714,13 +717,17 @@ describe('catalog-addressed navigation', () => {
     await b.svc.refreshSubagents(sid('root'))
     b.svc.setSubagentCatalogOpen(sid('root'), false)
 
-    expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot')
+    expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({
+      title: 'Investigate startup',
+      displayTitle: 'Investigate startup',
+      projectionValues: { title: 'Investigate startup' },
+    })
     expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined()
     expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
     expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce()
   })
 
-  it('uses catalog labels for a listed addressed route', async ({ bench }) => {
+  it('keeps projected titles in standard list rows for an addressed route', async ({ bench }) => {
     const b = bench()
     b.mock.remote.subagents.list.mockImplementation((payload) => {
       const parentSessionId = payload
@@ -746,7 +753,10 @@ describe('catalog-addressed navigation', () => {
     })
     await feedList(b, [
       { id: 'root' },
-      { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
+      {
+        id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent',
+        projections: { title: 'Child session title' },
+      },
       { id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
     ])
     await b.svc.refreshSubagents(sid('root'))
@@ -756,7 +766,11 @@ describe('catalog-addressed navigation', () => {
     }, { source: 'controllerOperation' })
     await _reference.ready
 
-    expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
+    expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
+      title: 'Child session title',
+      displayTitle: 'Child session title',
+      projectionValues: { title: 'Child session title' },
+    })
     expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
   })
 

+ 1 - 0
packages/client/ui-chat/package.json

@@ -82,6 +82,7 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",

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

@@ -58,8 +58,10 @@ async function bench() {
   runtime.ctx.provide('sidebarRight', sidebarRight as never)
   const sidebarRightTabs = {
     get: vi.fn<(kind: string) => object | undefined>(() => ({})),
+    register: vi.fn(() => () => {}),
   }
   runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never)
+  runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never)
   const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
     () => Promise.resolve({ ok: true, value: { opened: true } }),
   )

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

@@ -44,6 +44,8 @@ async function bench() {
   } as never)
   runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never)
+  runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never)
+  runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never)
   const openSession = vi.fn<(id: SessionId) => void>()
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {

+ 3 - 0
packages/client/ui-chat/tsconfig.json

@@ -59,6 +59,9 @@
     {
       "path": "../../session/session-turn-outline"
     },
+    {
+      "path": "../../subagent/subagent"
+    },
     {
       "path": "../../settings/settings"
     },

+ 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: c611304e5a359ef5556b232ea9c3236a95f56897
-README.zh.md: fad2daa506414d731936f3a60ed7278f92eaae94
+README.md: bc3b582f9d80e97c29e27dcaa680dc064ec30deb
+README.zh.md: 21881e7933cdf46cf9e27a66f076fc40c48e4b1a

+ 2 - 2
packages/client/ui-conversation/README.md

@@ -48,7 +48,7 @@ Claimed commands retain their identity and highlight when only their arguments a
 
 Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
 
-The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes one strict-Session local position, `views`; its default adapter renders the existing `conversation.session` entry, so that body and the Header retain one shared strict-Session store. Another occurrence can replace `views` without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
+The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
 
 A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites.
 
@@ -56,7 +56,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
 
-Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
+Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
 
 The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
 

+ 2 - 2
packages/client/ui-conversation/README.zh.md

@@ -48,7 +48,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
 
-本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开一个 strict-Session 局部位置 `views`;其默认 adapter 渲染现有 `conversation.session` entry,因此该 body 与 Header 继续共享一个 strict-Session store。其他 occurrence 可以替换 `views`,且不会渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
+本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
 
 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
 
@@ -56,7 +56,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
 
-活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
+主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
 
 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
 

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

@@ -255,7 +255,10 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       'conversation.hero.workspace': { kind: 'single', scope: 'root' },
       'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
     },
-    slots: { views: { scope: 'session' } },
+    slots: {
+      views: { scope: 'session' },
+      widthControls: { scope: 'root' },
+    },
     inject: (sessionId: SessionId | undefined): ConversationInjected => ({
       hooks: {
         composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),

+ 20 - 6
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -126,7 +126,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /** Conversation shell beneath its root-scoped main-panel entry. */
     'main.conversation': { kind: 'single'; scope: 'session-maybe' }
     /** Strict per-Session Conversation body. */
-    'conversation.session': { kind: 'single'; scope: 'session' }
+    'conversation.session': {
+      kind: 'single'
+      scope: 'session'
+      owner: { view?: string }
+    }
     /** Strict per-Session title, actions, and View navigation. */
     'conversation.session.header': { kind: 'single'; scope: 'session' }
     /** Optional replacement for one Session breadcrumb title. */
@@ -224,6 +228,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
       locale: 'conversation'
       slots: {
         views: { scope: 'session' }
+        widthControls: { scope: 'root'; props: ConversationWidthControlsInputProps }
       }
     }
   }
@@ -417,14 +422,19 @@ export type ConversationSlotProps =
   & PropsRenderSlots<'conversation.session.header'>
   & PropsRenderFactories
 
-/** Main-host inputs for one reusable Conversation content occurrence. */
+/** Inputs shared by main and embedded Conversation content occurrences. */
 export interface ConversationContentInputProps {
+  variant: 'main' | 'embedded'
   phase: 'settling' | 'hero' | 'active'
   hero: boolean
-  onHandleStart: () => number
-  onHandleDrag: (width: number) => void
-  onHandleCommit: (width: number) => void
-  onHandleEnd: () => void
+}
+
+/** Values passed from shared content to its occurrence-selected width controls. */
+export interface ConversationWidthControlsInputProps {
+  /** Mounted Conversation body measured and styled by the selected controls. */
+  container: HTMLDivElement | null
+  /** Current body phase; handles render only for an active transcript. */
+  phase: ConversationContentInputProps['phase']
 }
 
 /** Full props of the reusable Conversation Factory definition. */
@@ -436,6 +446,10 @@ export type ConversationStore = ReturnType<typeof createConversationStore>
 /** Full props of the Factory's caller-selectable Conversation View position. */
 export type ConversationViewsProps = FactoryLocalComponentPropsOf<'conversation.content', 'views'>
 
+/** Full props of the Factory's caller-selected width-control position. */
+export type ConversationWidthControlsProps =
+  FactoryLocalComponentPropsOf<'conversation.content', 'widthControls'>
+
 /** Full props of the strict Session body. */
 export type ConversationSessionSlotProps =
   PropsRuntime<'conversation.session'>

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

@@ -57,6 +57,7 @@ export type {
   ConversationContentInputProps, ConversationContentProps,
   ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps,
   ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationViewsProps,
+  ConversationWidthControlsInputProps, ConversationWidthControlsProps,
   ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
   HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
   MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,

+ 20 - 131
packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx

@@ -5,134 +5,29 @@ import type { ConversationContentProps, ConversationViewsProps, InputZone } from
 import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
 import css from './ConversationRoot.module.css'
 
-const WHEEL_DELTA_LINE = 1
-const WHEEL_DELTA_PAGE = 2
-const FALLBACK_WHEEL_LINE_PX = 16
-
 function ConversationSessionView({ renderSlot }: ConversationViewsProps) {
   return renderSlot('conversation.session', {})
 }
 
-/** Convert a wheel event's vertical delta to scrollport pixels. */
-function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number {
-  if (event.deltaMode === WHEEL_DELTA_LINE) {
-    const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight)
-    return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX)
-  }
-  if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight
-  return event.deltaY
-}
-
-/** One transcript width handle: pointer capture + rAF-throttled symmetric
- * resize (both sides write the one centered width, so outward travel widens
- * by 2× the pointer distance). pointermove publishes the pointer's Y as a CSS
- * variable so the glow indicator rides it. Mirrors ui-layout AppFrame's
- * DragHandle capture model. */
-function WidthHandle(props: {
-  side: 'left' | 'right'
-  onStart: () => number
-  onDrag: (width: number) => void
-  onCommit: (width: number) => void
-  onEnd: () => void
-}) {
-  const [dragging, setDragging] = useState(false)
-  const base = useRef(0)
-  const origin = useRef(0)
-  const latest = useRef(0)
-  const frame = useRef<number | null>(null)
-  const callbacks = useRef(props)
-  callbacks.current = props
-
-  const outwardWidth = () => {
-    const dx = latest.current - origin.current
-    const outward = callbacks.current.side === 'right' ? dx : -dx
-    return base.current + outward * 2
-  }
-  const cancelFrame = () => {
-    if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
-  }
-  const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
-    if (e.button !== 0) return
-    e.preventDefault()
-    e.currentTarget.setPointerCapture(e.pointerId)
-    origin.current = e.clientX
-    latest.current = e.clientX
-    base.current = callbacks.current.onStart()
-    setDragging(true)
-  }, [])
-  const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
-    const box = e.currentTarget.getBoundingClientRect()
-    e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`)
-    if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
-    latest.current = e.clientX
-    frame.current ??= requestAnimationFrame(() => {
-      frame.current = null
-      callbacks.current.onDrag(outwardWidth())
-    })
-  }, [])
-  const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
-    if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
-    e.currentTarget.releasePointerCapture(e.pointerId)
-    cancelFrame()
-    latest.current = e.clientX
-    // Only a gesture with actual travel commits: a press-and-release on a
-    // window-clamped width must not overwrite the wider stored preference
-    // with the clamped display value.
-    if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth())
-    setDragging(false)
-    callbacks.current.onEnd()
-  }, [])
-  // Releasing the button outside the window delivers pointercancel (or drops
-  // the capture silently) instead of pointerup; without this the glow's
-  // data-dragging state sticks on. The gesture is abandoned uncommitted —
-  // onEnd republishes the stored preference. releasePointerCapture inside
-  // onPointerUp also fires lostpointercapture, so this runs (idempotently)
-  // after every normal drag end too; keep both paths.
-  const onPointerCancel = useCallback(() => {
-    cancelFrame()
-    setDragging(false)
-    callbacks.current.onEnd()
-  }, [])
-  const onWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
-    const body = e.currentTarget.parentElement
-    /* v8 ignore next -- a width handle renders only inside the Conversation body. */
-    if (body === null) return
-    const scrollport = body.querySelector<HTMLElement>(':scope > [data-conversation-scroll]')
-    /* v8 ignore next -- the Conversation body always contains its direct scroll element. */
-    if (scrollport === null) return
-    if (e.ctrlKey || e.deltaY === 0) return
-    scrollport.scrollBy({ top: wheelDeltaY(e, scrollport) })
-  }, [])
-
-  return (
-    <div
-      className={css.widthHandle}
-      data-side={props.side}
-      data-width-handle={props.side}
-      data-dragging={dragging || undefined}
-      onPointerDown={onPointerDown}
-      onPointerMove={onPointerMove}
-      onPointerUp={onPointerUp}
-      onPointerCancel={onPointerCancel}
-      onLostPointerCapture={onPointerCancel}
-      onWheel={onWheel}
-    />
-  )
+function NoConversationWidthControls() {
+  return null
 }
 
 /**
- * Render the existing Conversation body, Composer, and width handles.
- * @param props - original Conversation seats plus MainPanel-derived phase and width callbacks.
- * @returns the unchanged Conversation body subtree.
+ * Render the shared Conversation body and its occurrence-selected local Components.
+ * @param props - Factory input, standard Session sources, and Conversation seats.
+ * @returns the Conversation view, Composer, and optional width controls.
  */
-export function ConversationContent({
-  sessionId, phase, hero, useSession, useSessions, useSessionStatus,
-  useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
-  selectWorkspace, t, useFactorySlot,
-  onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
-}: ConversationContentProps) {
+export function ConversationContent(props: ConversationContentProps) {
+  const {
+    sessionId, phase, hero, useSession, useSessions, useSessionStatus,
+    useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
+    selectWorkspace, t, useFactorySlot,
+  } = props
   const session = useSession(snapshot => snapshot)
   const Views = useFactorySlot('views', ConversationSessionView)
+  const WidthControls = useFactorySlot('widthControls', NoConversationWidthControls)
+  const [body, setBody] = useState<HTMLDivElement | null>(null)
   const pendingInteraction = useSessionStatus(snapshot =>
     sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction)
   const inputState = useInput(s => s)
@@ -285,23 +180,17 @@ export function ConversationContent({
   )
 
   return (
-    <div className={css.body}>
+    <div
+      ref={setBody}
+      className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)}
+      data-conversation-content=""
+      data-content-phase={phase}
+    >
       <div className={css.scrollBody} data-conversation-scroll="">
         {sessionId === undefined ? null : <Views />}
         {composerSeat}
       </div>
-      {/* Width handles only while a transcript is on screen; the hero has no
-          content column to size. */}
-      {phase === 'active' && (['left', 'right'] as const).map(side => (
-        <WidthHandle
-          key={side}
-          side={side}
-          onStart={onHandleStart}
-          onDrag={onHandleDrag}
-          onCommit={onHandleCommit}
-          onEnd={onHandleEnd}
-        />
-      ))}
+      <WidthControls container={body} phase={phase} />
     </div>
   )
 }

+ 5 - 93
packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx

@@ -1,38 +1,8 @@
-import { useCallback, useRef } from 'react'
 import type { ConversationSlotProps } from '../contract/slots.ts'
 import { conversationPhase } from '../contract/snapshot.ts'
+import { ConversationWidthControls } from './ConversationWidthControls.tsx'
 import css from './ConversationRoot.module.css'
 
-/** localStorage key for the dragged transcript width preference (px). */
-const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth'
-/** Floor for a dragged content width; matches the layout center-column minimum. */
-const CONTENT_MIN = 640
-/** Column budget the content must leave free: 88px per side keeps the width
- * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a
- * larger dragged width would push its own handles off the column and leave no
- * way to drag back. */
-const CONTENT_EDGE_BUDGET = 176
-
-/** Reads the persisted width preference; durable-storage boundary, so a
- * missing or corrupt value resolves to "no preference".
- * @returns the stored width in px, or null when unset or invalid. */
-function readWidthPreference(): number | null {
-  const raw = localStorage.getItem(WIDTH_PREF_KEY)
-  if (raw === null) return null
-  const value = Number(raw)
-  return Number.isFinite(value) && value > 0 ? value : null
-}
-
-/** Resolves the content width the CSS axis would show for a column width.
- * @param columnWidth - the conversation column's rendered width in px.
- * @param preference - the dragged preference, or null for the adaptive clamp.
- * @returns the resolved content width in px (mirrors the CSS clamp). */
-function resolveContentWidth(columnWidth: number, preference: number | null): number {
-  const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET)
-  if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max)
-  return Math.max(680, Math.min(columnWidth * 0.64, 920))
-}
-
 /**
  * Render the existing main Conversation frame around the extracted content.
  * @param props - the original `main.conversation` Slot props.
@@ -48,63 +18,6 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
   const openState = session?.openState
   const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
 
-  // Publishes the column's live width as --dsh-conversation-column-width so
-  // the shared width axis can adapt (see the .root CSS), and re-clamps a
-  // dragged preference against the shrunken column WITHOUT rewriting the
-  // stored preference — widening the window restores it (the AppFrame
-  // sidebar-drag rule). Same callback-ref pattern as the seat observer.
-  const rootEl = useRef<HTMLDivElement | null>(null)
-  const rootObserver = useRef<ResizeObserver | null>(null)
-  const publishWidths = useCallback((root: HTMLDivElement): void => {
-    const column = root.offsetWidth
-    root.style.setProperty('--dsh-conversation-column-width', `${column}px`)
-    const preference = readWidthPreference()
-    if (preference === null) {
-      root.style.removeProperty('--dsh-chat-user-width')
-    } else {
-      root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`)
-    }
-  }, [])
-  const rootResizeRef = useCallback((root: HTMLDivElement | null): void => {
-    rootObserver.current?.disconnect()
-    rootObserver.current = null
-    rootEl.current = root
-    if (root === null) return
-    rootObserver.current = new ResizeObserver(() => { publishWidths(root) })
-    rootObserver.current.observe(root)
-    publishWidths(root)
-  }, [publishWidths])
-
-  // Drag plumbing for the two width handles: onStart snapshots the resolved
-  // width (grabbing a clamped column must not jump back to the raw stored
-  // preference), onDrag publishes only the live clamped style, onCommit
-  // persists the width of a gesture that actually travelled, and onEnd
-  // republishes from storage — an uncommitted press leaves the stored
-  // preference untouched.
-  const onHandleStart = useCallback((): number => {
-    const root = rootEl.current
-    /* v8 ignore next -- handles render inside the root, so the ref is always attached. */
-    if (root === null) return 680
-    return resolveContentWidth(root.offsetWidth, readWidthPreference())
-  }, [])
-  const onHandleDrag = useCallback((width: number): void => {
-    const root = rootEl.current
-    /* v8 ignore next -- handles render inside the root, so the ref is always attached. */
-    if (root === null) return
-    const clamped = resolveContentWidth(root.offsetWidth, width)
-    root.style.setProperty('--dsh-chat-user-width', `${clamped}px`)
-  }, [])
-  const onHandleCommit = useCallback((width: number): void => {
-    const root = rootEl.current
-    /* v8 ignore next -- handles render inside the root, so the ref is always attached. */
-    if (root === null) return
-    localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`)
-  }, [])
-  const onHandleEnd = useCallback((): void => {
-    const root = rootEl.current
-    if (root !== null) publishWidths(root)
-  }, [publishWidths])
-
   // While a session is still replaying (loading + blank) the hero/docked
   // choice is unknowable — render the composer hidden instead of flashing
   // the centered hero and snapping to the docked bar (or vice versa).
@@ -128,15 +41,14 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
   const phase = settling ? 'settling' : hero ? 'hero' : 'active'
 
   return (
-    <div ref={rootResizeRef} className={css.root} data-phase={phase}>
+    <div className={css.root} data-phase={phase}>
       {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
       {renderFactorySlot('conversation.content', {
+        variant: 'main',
         phase,
         hero,
-        onHandleStart,
-        onHandleDrag,
-        onHandleCommit,
-        onHandleEnd,
+      }, {
+        slots: { widthControls: ConversationWidthControls },
       })}
     </div>
   )

+ 27 - 30
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -5,32 +5,6 @@
   height: 100%;
   min-width: 0;
   background: var(--dsw-alias-bg-base);
-
-  /* Shared width axis for the whole column: one content width W
-     (--dsh-chat-content-width) for the transcript, the dock cards
-     (todo/goal/queue: card minus four insets, 4 x 8 = 32), and business-owned
-     takeover cards; the input card alone is W + 32px.
-     The relation also holds when a narrow viewport shrinks everything: the
-     chat scroller and the takeover frames pad clearance + 16px per side while
-     the input card clears the bare clearance, so the input card stays exactly
-     content + 32px at every width. Declared on the root because the
-     transcript and the composer seat are sibling subtrees.
-
-     W adapts to the column, not the viewport (a collapsed sidebar widens the
-     column without resizing the window): the component publishes the column's
-     live width as --dsh-conversation-column-width (px, via ResizeObserver)
-     and the clamp keeps a 680px floor (a step under the figma
-     748px — the full-width reading felt wide on every screen), takes 64% of
-     wider columns, and caps at 920px for line-length
-     readability. A dragged user preference (--dsh-chat-user-width, published
-     by the width handles) replaces the adaptive term wholesale. */
-  --dsh-chat-content-width: var(
-    --dsh-chat-user-width,
-    clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px)
-  );
-  --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
-  --dsh-composer-side-clearance: 16px;
-  --dsh-composer-dock-inset: 8px;
 }
 
 /* 76px with its rule: the height of the Sidebar's tab strip and header row
@@ -377,6 +351,25 @@
   flex: 1;
   flex-direction: column;
   min-height: 0;
+
+  /* Shared width axis for the transcript, dock cards, composer, and takeover
+     cards. Main width controls publish the live body width and optional user
+     preference; embedded occurrences override this adaptive axis below. */
+  --dsh-chat-content-width: var(
+    --dsh-chat-user-width,
+    clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px)
+  );
+  --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
+  --dsh-composer-side-clearance: 16px;
+  --dsh-composer-dock-inset: 8px;
+}
+
+.embeddedBody {
+  overflow: hidden;
+  --dsh-chat-content-width: min(calc(100% - 32px), 920px);
+  --dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
+  --dsh-composer-side-clearance: 8px;
+  --dsh-composer-dock-inset: 8px;
 }
 
 .scrollBody {
@@ -402,12 +395,14 @@
   margin: 2px;
 }
 
-.root[data-phase='active'] .viewArea {
+.root[data-phase='active'] .viewArea,
+.embeddedBody[data-content-phase='active'] .viewArea {
   flex: 1 0 auto;
   min-height: auto;
 }
 
-.root[data-phase='active'] .composerSeat {
+.root[data-phase='active'] .composerSeat,
+.embeddedBody[data-content-phase='active'] .composerSeat {
   position: sticky;
   bottom: 0;
   /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
@@ -510,13 +505,15 @@
 
 /* Hero: the composer sits inside the session scroll body; center there so
    the tree seat matches active (sticky footer) without a Root remount. */
-.root[data-phase='hero'] .scrollBody {
+.root[data-phase='hero'] .scrollBody,
+.embeddedBody[data-content-phase='hero'] .scrollBody {
   justify-content: center;
   overflow-y: auto;
 }
 
 /* Settling (session replaying, hero/docked unknown): keep the composer seat
    mounted but invisible so no wrong layout flashes before the phase lands. */
-.root[data-phase='settling'] .composerSeat {
+.root[data-phase='settling'] .composerSeat,
+.embeddedBody[data-content-phase='settling'] .composerSeat {
   visibility: hidden;
 }

+ 177 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx

@@ -0,0 +1,177 @@
+import { useCallback, useLayoutEffect, useRef, useState } from 'react'
+import type { ConversationWidthControlsProps } from '../contract/slots.ts'
+import css from './ConversationRoot.module.css'
+
+/** localStorage key for the dragged transcript width preference (px). */
+const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth'
+/** Floor for a dragged content width; matches the layout center-column minimum. */
+const CONTENT_MIN = 640
+/** Horizontal room reserved for both handles and their safe edge zones. */
+const CONTENT_EDGE_BUDGET = 176
+const WHEEL_DELTA_LINE = 1
+const WHEEL_DELTA_PAGE = 2
+const FALLBACK_WHEEL_LINE_PX = 16
+
+/** Read a valid persisted width preference, or null when absent or corrupt. */
+function readWidthPreference(): number | null {
+  const raw = localStorage.getItem(WIDTH_PREF_KEY)
+  if (raw === null) return null
+  const value = Number(raw)
+  return Number.isFinite(value) && value > 0 ? value : null
+}
+
+/** Resolve the width displayed for one measured Conversation column. */
+function resolveContentWidth(columnWidth: number, preference: number | null): number {
+  const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET)
+  if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max)
+  return Math.max(680, Math.min(columnWidth * 0.64, 920))
+}
+
+/** Convert a wheel event's vertical delta to scrollport pixels. */
+function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number {
+  if (event.deltaMode === WHEEL_DELTA_LINE) {
+    const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight)
+    return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX)
+  }
+  if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight
+  return event.deltaY
+}
+
+/** One pointer-captured transcript width handle. */
+function WidthHandle(props: {
+  side: 'left' | 'right'
+  onStart: () => number
+  onDrag: (width: number) => void
+  onCommit: (width: number) => void
+  onEnd: () => void
+}) {
+  const [dragging, setDragging] = useState(false)
+  const base = useRef(0)
+  const origin = useRef(0)
+  const latest = useRef(0)
+  const frame = useRef<number | null>(null)
+  const callbacks = useRef(props)
+  callbacks.current = props
+
+  const outwardWidth = () => {
+    const dx = latest.current - origin.current
+    const outward = callbacks.current.side === 'right' ? dx : -dx
+    return base.current + outward * 2
+  }
+  const cancelFrame = () => {
+    if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
+  }
+  const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    if (event.button !== 0) return
+    event.preventDefault()
+    event.currentTarget.setPointerCapture(event.pointerId)
+    origin.current = event.clientX
+    latest.current = event.clientX
+    base.current = callbacks.current.onStart()
+    setDragging(true)
+  }, [])
+  const onPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    const box = event.currentTarget.getBoundingClientRect()
+    event.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${event.clientY - box.top}px`)
+    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
+    latest.current = event.clientX
+    frame.current ??= requestAnimationFrame(() => {
+      frame.current = null
+      callbacks.current.onDrag(outwardWidth())
+    })
+  }, [])
+  const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
+    event.currentTarget.releasePointerCapture(event.pointerId)
+    cancelFrame()
+    latest.current = event.clientX
+    // A press-only gesture must not overwrite a wider preference with its window-clamped display value.
+    if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth())
+    setDragging(false)
+    callbacks.current.onEnd()
+  }, [])
+  const onPointerCancel = useCallback(() => {
+    // Cancellation abandons persistence and restores the saved width through onEnd.
+    cancelFrame()
+    setDragging(false)
+    callbacks.current.onEnd()
+  }, [])
+  const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
+    const body = event.currentTarget.parentElement
+    /* v8 ignore next -- a width handle renders only inside the Conversation body. */
+    if (body === null) return
+    const scrollport = body.querySelector<HTMLElement>(':scope > [data-conversation-scroll]')
+    /* v8 ignore next -- the Conversation body always contains its direct scroll element. */
+    if (scrollport === null) return
+    if (event.ctrlKey || event.deltaY === 0) return
+    scrollport.scrollBy({ top: wheelDeltaY(event, scrollport) })
+  }, [])
+
+  return (
+    <div
+      className={css.widthHandle}
+      data-side={props.side}
+      data-width-handle={props.side}
+      data-dragging={dragging || undefined}
+      onPointerDown={onPointerDown}
+      onPointerMove={onPointerMove}
+      onPointerUp={onPointerUp}
+      onPointerCancel={onPointerCancel}
+      onLostPointerCapture={onPointerCancel}
+      onWheel={onWheel}
+    />
+  )
+}
+
+/**
+ * Install the main Conversation width axis and render its drag handles.
+ * @param props - Mounted Conversation body and current presentation phase.
+ * @returns two active-phase width handles, or no controls outside the active phase.
+ */
+export function ConversationWidthControls({ container, phase }: ConversationWidthControlsProps) {
+  const publishWidths = useCallback((container: HTMLDivElement): void => {
+    const target = container.parentElement ?? container
+    const column = container.offsetWidth
+    target.style.setProperty('--dsh-conversation-column-width', `${column}px`)
+    const preference = readWidthPreference()
+    if (preference === null) target.style.removeProperty('--dsh-chat-user-width')
+    else target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`)
+  }, [])
+
+  useLayoutEffect(() => {
+    if (container === null) return
+    const observer = new ResizeObserver(() => { publishWidths(container) })
+    observer.observe(container)
+    publishWidths(container)
+    return () => { observer.disconnect() }
+  }, [container, publishWidths])
+
+  const onStart = useCallback((): number => {
+    if (container === null) return 680
+    return resolveContentWidth(container.offsetWidth, readWidthPreference())
+  }, [container])
+  const onDrag = useCallback((width: number): void => {
+    if (container === null) return
+    const target = container.parentElement ?? container
+    target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(container.offsetWidth, width)}px`)
+  }, [container])
+  const onCommit = useCallback((width: number): void => {
+    if (container === null) return
+    localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(container.offsetWidth, width)}`)
+  }, [container])
+  const onEnd = useCallback((): void => {
+    if (container !== null) publishWidths(container)
+  }, [container, publishWidths])
+
+  if (container === null || phase !== 'active') return null
+  return (['left', 'right'] as const).map(side => (
+    <WidthHandle
+      key={side}
+      side={side}
+      onStart={onStart}
+      onDrag={onDrag}
+      onCommit={onCommit}
+      onEnd={onEnd}
+    />
+  ))
+}

+ 4 - 3
packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx

@@ -11,7 +11,7 @@ import css from './ConversationRoot.module.css'
  * @returns the active view area, or null while the Session remains blank.
  */
 export function DefaultConversationViews({
-  useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
+  view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
   renderSlot, bindDraftMirror, openView,
 }: ConversationSessionSlotProps) {
   const tabs = useConversationViews(value => value)
@@ -32,13 +32,14 @@ export function DefaultConversationViews({
   }, [inputActions])
 
   if (session.blank && conversationPhase(session, conversation) === 'blank') return null
+  const viewId = view ?? active?.id
   return (
     <div className={css.viewArea}>
-      {active !== undefined && renderSlot('conversation.view', {
+      {viewId !== undefined && renderSlot('conversation.view', {
         viewRequest,
         openView,
         completeViewRequest: actions.completeViewRequest,
-      }, { only: active.id })}
+      }, { only: viewId })}
     </div>
   )
 }

+ 16 - 10
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -309,7 +309,9 @@ function mount(
       : (opts?.fallback ?? null)
   )) as ConversationContentProps['renderSlotChain']
   const SessionProvider: ConversationContentProps['SessionProvider'] = ({ children }) => children
-  const renderFactorySlot = ((_name: string, input: ConversationContentInputProps) => {
+  const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: {
+    slots?: Record<string, (props: never) => ReactNode>
+  }) => {
     const common: ConversationViewsProps = {
       sessionId: SID,
       SessionProvider,
@@ -333,7 +335,9 @@ function mount(
       selectWorkspace: retargetWorkspace,
       t,
     }
-    const useFactorySlot = (() => StableConversationViews) as ConversationContentProps['useFactorySlot']
+    const useFactorySlot = ((name: string, fallback: (props: never) => ReactNode) => (
+      name === 'views' ? StableConversationViews : factoryOptions?.slots?.[name] ?? fallback
+    )) as ConversationContentProps['useFactorySlot']
     return (
       <FactoryViewsTestContext.Provider value={common}>
         <ConversationContent {...({ ...common, ...input, useFactorySlot })} />
@@ -667,11 +671,12 @@ describe('ConversationRoot resident composer', () => {
 
   it('publishes the column width as a px variable for the shared width axis', () => {
     const b = mount(sessionSnapshotOf())
-    const root = b.view.container.querySelector('[data-phase]') as HTMLElement
+    const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement
+    const root = content.parentElement as HTMLElement
     // jsdom offsetWidth is 0 until faked: the observer publishes whatever the
     // layout reports, and the CSS clamp() floors the axis at 680px either way.
-    Object.defineProperty(root, 'offsetWidth', { value: 1200, configurable: true })
-    act(() => { fireResize(root) })
+    Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true })
+    act(() => { fireResize(content) })
     expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px')
     // No dragged preference: the user-width override stays absent so the
     // adaptive clamp term applies.
@@ -680,9 +685,10 @@ describe('ConversationRoot resident composer', () => {
 
   it('drag → persist → window clamp round-trip on a width handle', () => {
     const b = mount(sessionSnapshotOf())
-    const root = b.view.container.querySelector('[data-phase]') as HTMLElement
-    Object.defineProperty(root, 'offsetWidth', { value: 1600, configurable: true })
-    act(() => { fireResize(root) })
+    const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement
+    const root = content.parentElement as HTMLElement
+    Object.defineProperty(content, 'offsetWidth', { value: 1600, configurable: true })
+    act(() => { fireResize(content) })
     const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement
     expect(handle).not.toBeNull()
     // jsdom lacks pointer capture: emulate per-element so hasPointerCapture
@@ -705,8 +711,8 @@ describe('ConversationRoot resident composer', () => {
       expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
       // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the
       // preference stays.
-      Object.defineProperty(root, 'offsetWidth', { value: 900, configurable: true })
-      act(() => { fireResize(root) })
+      Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true })
+      act(() => { fireResize(content) })
       expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
       expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
       // A press without travel (a real double-click delivers two such

+ 2 - 2
packages/client/ui-reference/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-reference/README.md
-README.md: 1a79a22425fff1c7323a3bcb55afd324decbe5e8
-README.zh.md: 7cde5e46e69f008e9fcfffeb3920184d90bddb60
+README.md: 6b5046f1c6300f5ec02d3737617019874762d47a
+README.zh.md: 99ba6fda18a73f5686f5e02403e48d5e556f9c3c

+ 1 - 1
packages/client/ui-reference/README.md

@@ -51,7 +51,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c
 
 ### Candidate flow
 
-For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome.
+For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files, direct subagents of the current Session, and other Sessions. Rows use the resolver's display title, which prefers a subagent's creation label while ordinary Sessions retain their projected title, and render under locale-owned group headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the current directory; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome.
 
 ### Serialization
 

+ 1 - 1
packages/client/ui-reference/README.zh.md

@@ -51,7 +51,7 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话
 
 ### 候选流程
 
-对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。
+对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再按文件、当前 Session 的直接 subagent、其他 Session 的确定性顺序排列。各行使用 resolver 的显示标题;subagent 优先使用创建标签,普通 Session 保持投影标题,并渲染在 locale 管理的分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。
 
 ### 序列化
 

+ 23 - 10
packages/client/ui-reference/src/client/index.ts

@@ -61,15 +61,26 @@ export function apply(ctx: ClientContext): void {
       const now = Date.now()
       const home = ctx.remote.$host.home
       const listed = sessions.list.getSnapshot().byId
+      const sessionRows = sessionItems.map((candidate) => {
+        const summary = listed[candidate.sessionId]
+        const child = summary?.origin === 'subagent' && summary.parentId === session.sessionId
+        return {
+          child,
+          row: sessionCandidate(
+            candidate,
+            candidate.displayTitle ?? candidate.label,
+            summary?.updatedAt ?? candidate.createdAt,
+            now,
+            home,
+            t(child ? 'section.subagents' : 'section.sessions'),
+            t,
+          ),
+        }
+      })
       return [
         ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)),
-        ...sessionItems.map(candidate => sessionCandidate(
-          candidate,
-          listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt,
-          now,
-          home,
-          t,
-        )),
+        ...sessionRows.filter(item => item.child).map(item => item.row),
+        ...sessionRows.filter(item => !item.child).map(item => item.row),
       ]
     },
     header(_session: ClientSessionContext, req) {
@@ -210,9 +221,11 @@ function fileCandidate(
 
 function sessionCandidate(
   candidate: SessionReferenceMentionCandidate,
+  label: string,
   updatedAt: number,
   now: number,
   home: string | undefined,
+  section: string,
   t: Translate,
 ) {
   const { unit, n } = relativeTime(updatedAt, now)
@@ -224,14 +237,14 @@ function sessionCandidate(
     : candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home)
   const value: ReferenceCandidateValue = {
     kind: 'session',
-    label: candidate.label,
+    label,
     mention: candidate.mention,
   }
   return {
-    name: candidate.label,
+    name: label,
     description: location === undefined ? age : `${location} · ${age}`,
     icon: 'session' as const,
-    section: t('section.sessions'),
+    section,
     value: JSON.stringify(value),
   }
 }

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

@@ -14,6 +14,7 @@ export const NS = 'reference'
  */
 export const zh = {
   'section.files': '文件与文件夹',
+  'section.subagents': '子智能体',
   'section.sessions': '对话',
   'candidate.noCwd': '(无工作目录)',
   'crumb.root': '工作区',
@@ -38,6 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
   'section.files': 'Files & folders',
+  'section.subagents': 'Subagents',
   'section.sessions': 'Sessions',
   'candidate.noCwd': '(no cwd)',
   'crumb.root': 'Workspace',

+ 63 - 1
packages/client/ui-reference/tests/browser-plugin.client.spec.ts

@@ -74,7 +74,12 @@ async function bench(
       mention: '@[Research](dsh-session:InNvdXJjZSI)',
     }],
   })),
-  listed: Record<string, { updatedAt: number }> = {},
+  listed: Record<string, {
+    updatedAt: number
+    origin?: 'subagent'
+    parentId?: SessionId
+    projectionValues?: { title?: string | null }
+  }> = {},
 ): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']>; source: InputTriggerSource }> {
   const ctx = new Context()
   ctx.provide('sidebarRight', { openResource: vi.fn() })
@@ -330,6 +335,63 @@ describe('candidates', () => {
     ])
   })
 
+  it('uses current Session titles and groups direct subagents above ordinary Sessions', async () => {
+    const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
+    const sessions = vi.fn(() => Promise.resolve({
+      ok: true as const,
+      value: [
+        {
+          sessionId: sid('ordinary'),
+          label: 'Ordinary title',
+          displayTitle: 'Ordinary title',
+          cwd: `${HOME}/project`,
+          sameWorkspace: true,
+          createdAt: CREATED_AT,
+          mention: '@[Ordinary title](dsh-session:Im9yZGluYXJ5Ig)',
+        },
+        {
+          sessionId: sid('worker'),
+          label: 'Investigate startup',
+          displayTitle: 'researcher',
+          cwd: `${HOME}/project`,
+          sameWorkspace: true,
+          createdAt: CREATED_AT,
+          mention: '@[researcher](dsh-session:IndvcmtlciI)',
+        },
+      ],
+    }))
+    const { source } = await bench(files, sessions, {
+      worker: {
+        updatedAt: UPDATED_AT,
+        origin: 'subagent', parentId: session.sessionId,
+        projectionValues: { title: 'Investigate startup' },
+      },
+      ordinary: { updatedAt: UPDATED_AT, projectionValues: { title: 'Ordinary title' } },
+    })
+    const candidates = await source.candidates(session, request('worker'))
+    expect(candidates.map(candidate => ({ name: candidate.name, section: candidate.section }))).toEqual([
+      { name: 'researcher', section: 'Subagents' },
+      { name: 'Ordinary title', section: 'Sessions' },
+    ])
+    const [candidate] = candidates
+    expect(source.onPick({
+      candidate: candidate!,
+      session,
+      position: 'inline',
+      via: 'menu',
+      action: 'pick',
+      span: { start: 0, end: 7, draftRev: 1 },
+    })).toEqual({
+      insert: {
+        source: 'reference',
+        ref: '@[researcher](dsh-session:IndvcmtlciI)',
+        label: 'researcher',
+        appearance: 'session',
+        clipboardText: '@[researcher](dsh-session:IndvcmtlciI)',
+      },
+    })
+  })
+
   it('reads a session opened moments ago as the present, not a zero distance', async () => {
     const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
     const sessions = vi.fn(() => Promise.resolve({

+ 2 - 2
packages/client/ui-sidebar-right/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-sidebar-right/README.md
-README.md: e838f952530d71510c14cbb948d64c8fac319787
-README.zh.md: a529c31c8abb4c4927683e128cc0938a155e51c0
+README.md: dbe09becc8f5f322720c21f89e5042a1f423e6f0
+README.zh.md: 357f9366298dcc4313068d78f73105691d82c129

+ 2 - 0
packages/client/ui-sidebar-right/README.md

@@ -90,6 +90,8 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
 
 `openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
 
+`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference.
+
 `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
 
 <a id="the-tab-domain"></a>

+ 2 - 0
packages/client/ui-sidebar-right/README.zh.md

@@ -90,6 +90,8 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
 
 `openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource://<type>/…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。
 
+`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。
+
 `close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。
 
 <a id="the-tab-domain"></a>

+ 2 - 0
packages/client/ui-sidebar-right/src/client/contract/slots.ts

@@ -113,6 +113,8 @@ export interface SidebarRightTabNavigation {
 export interface SidebarRightTabPlacement {
   /** Land a new tab in this pane instead. */
   readonly paneId?: PaneId
+  /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */
+  readonly preferNewPane?: boolean
   /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */
   readonly revealIfOpened?: boolean
   /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */

+ 18 - 1
packages/client/ui-sidebar-right/src/client/service.ts

@@ -115,6 +115,8 @@ export interface SidebarRightBinding {
 export interface SidebarRightPlacement {
   /** Land a new tab in this pane instead of the active docked one. */
   readonly paneId?: PaneId
+  /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */
+  readonly preferNewPane?: boolean
   /** Take this tab's place — its pane and its strip slot — and close it in the same step. */
   readonly replaceTab?: TabId
   /**
@@ -378,15 +380,30 @@ export class SidebarRightController implements ISidebarRight {
     placement: SidebarRightPlacement,
     params: SidebarRightNavigationParams,
   ): void {
+    const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]
+      ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined)
+    const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout)
+    const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane]
+    const preferNewPane = placement.preferNewPane === true
+      && placement.replaceTab === undefined
+      && surface !== undefined
+      && target?.kind === 'pane'
+      && target.host === 'dock'
+      && target.tabs.length > 0
+      && canSplit(surface.layout)
+      && dockPaneIds(surface.layout).length < 2
+      && this.binding?.sessionId === sessionId
+      && this.binding.canSplitPane(target.id)
     const commit = (): void => { actions.openContent(sessionId, {
       kind: claim.kind,
       contentId: claim.contentId,
       title: claim.title,
       ...placement.paneId === undefined ? {} : { paneId: placement.paneId },
+      ...preferNewPane ? { preferNewPane: true } : {},
       ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab },
       ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
     }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) }
-    const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout
+    const layout = surface?.layout
     const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab]
     const revealed = layout === undefined || placement.revealIfOpened === false
       ? undefined : findContentTab(layout, claim.contentId, claim.kind)

+ 26 - 12
packages/client/ui-sidebar-right/src/client/stores.ts

@@ -32,7 +32,7 @@ import type {
   DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord,
 } from '@deepseek-ai/dsh-client-ui-dockkit'
 import {
-  activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, getPane,
+  activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findContentTab, findPaneContentTab, findTabPane, getPane,
   planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded,
   planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward,
 } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -87,6 +87,8 @@ export interface OpenContentIntent {
   readonly title: string
   /** Land a new tab in this pane. */
   readonly paneId?: PaneId
+  /** Split the target pane and put new content alone in the new pane. */
+  readonly preferNewPane?: boolean
   /** Take this tab's pane and slot, and close it in the same entry. */
   readonly replaceTab?: TabId
   /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */
@@ -319,18 +321,30 @@ export function createSidebarRightStore(
           // cross-pane reveal is for resources only.
           const page = contentId === pageAddress(kind)
           const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined
-          const planned = held !== undefined
-            ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held }
-            : planOpenContent(state, mint, {
-              kind,
-              contentId,
-              title,
-              ...paneId === undefined ? {} : { paneId },
-              ...index === undefined ? {} : { index },
-              ...page
-                ? { revealIfOpened: false }
-                : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened },
+          const revealed = page
+            ? held
+            : intent.revealIfOpened === false ? undefined : findContentTab(state, contentId, kind)
+          let openedInNewPane: TabId | undefined
+          const split = intent.preferNewPane === true && replace === undefined && revealed === undefined
+            ? planSplitPane(state, mint, paneId, (id) => {
+              openedInNewPane = id
+              return { id, kind, contentId, title }
             })
+            : []
+          const planned = revealed !== undefined
+            ? { ops: [{ type: 'focusTab' as const, tabId: revealed }], tabId: revealed }
+            : openedInNewPane !== undefined
+              ? { ops: split, tabId: openedInNewPane }
+              : planOpenContent(state, mint, {
+                kind,
+                contentId,
+                title,
+                ...paneId === undefined ? {} : { paneId },
+                ...index === undefined ? {} : { index },
+                ...page
+                  ? { revealIfOpened: false }
+                  : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened },
+              })
           ops.push(...planned.ops)
           if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace })
           settled(planned.tabId)

+ 1 - 0
packages/client/ui-sidebar-right/src/client/tab-domain.ts

@@ -158,6 +158,7 @@ export class TabDomain {
         ? { replaceTab: tabId }
         : held.paneId === undefined ? {} : { paneId: held.paneId },
       ...placement.paneId === undefined ? {} : { paneId: placement.paneId },
+      ...placement.preferNewPane === undefined ? {} : { preferNewPane: placement.preferNewPane },
       ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
     })
     const held: Held = {

+ 49 - 0
packages/client/ui-sidebar-right/tests/service.client.spec.ts

@@ -169,6 +169,39 @@ describe('SidebarRightController — opening', () => {
     expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left)
   })
 
+  it('opens new content alone in a preferred pane and falls back to the current pane at the limit', () => {
+    const { controller, publish, layout, tabOf, entries, expand } = harness()
+    expand()
+    publish()
+    const before = entries()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true })
+    const a = tabOf('a.txt')
+    const pane = findTabPane(layout(), a)
+    expect(dockPaneIds(layout())).toHaveLength(2)
+    expect(pane.tabs).toEqual([a])
+    expect(entries()).toBe(before + 1)
+
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/b.txt', { preferNewPane: true })
+    expect(dockPaneIds(layout())).toHaveLength(2)
+    expect(findTabPane(layout(), tabOf('b.txt')).id).toBe(pane.id)
+  })
+
+  it('falls back when the pane is too narrow and reveals existing content without splitting', () => {
+    const { controller, publish, layout, tabOf, room, expand } = harness()
+    expand()
+    publish()
+    room.allowed = false
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true })
+    expect(dockPaneIds(layout())).toHaveLength(1)
+    publish()
+
+    room.allowed = true
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true })
+    expect(dockPaneIds(layout())).toHaveLength(1)
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt'))
+  })
+
   it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => {
     const { controller, publish, layout, tabOf, entries } = harness()
     publish()
@@ -462,6 +495,22 @@ describe('SidebarRightController — a tab\'s own actions', () => {
   const A_TXT = 'dsh-resource://file/session/s-test/a.txt'
   const B_TXT = 'dsh-resource://file/session/s-test/b.txt'
 
+  it('opens into an adopted store before that store has created its session surface', () => {
+    const { controller, adopt, instance, titles } = harness()
+    const releaseOwn = adopt(SESSION, instance)
+    const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER)
+    const releaseBinding = controller.bind({
+      sessionId: OTHER,
+      actions: other.actions,
+      surfaces: other.getSnapshot().bySession,
+      canSplitPane: () => true,
+    })
+    controller.openResourceIn(SESSION, A_TXT)
+    expect(titles()).toContain('a.txt')
+    releaseBinding()
+    releaseOwn()
+  })
+
   it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => {
     const { controller, adopt, instance, publish, layout, expand } = harness()
     const releaseOwn = adopt(SESSION, instance)

+ 6 - 3
packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts

@@ -195,16 +195,19 @@ describe('TabDomain — a tab\'s own actions', () => {
     expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId)
   })
 
-  it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => {
+  it('passes placement options through, so a tab may prefer a new pane and open a second copy', () => {
     const { domain, navigator, controller, current } = harness()
     const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
     domain.sync(SESSION, current())
     const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId))
-    tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false })
+    tabActions.openResource('dsh-resource://file/session/s-one/a.txt', {
+      preferNewPane: true,
+      revealIfOpened: false,
+    })
     expect(navigator.openResourceIn).toHaveBeenLastCalledWith(
       SESSION,
       'dsh-resource://file/session/s-one/a.txt',
-      { paneId: current().rootId, revealIfOpened: false },
+      { paneId: current().rootId, preferNewPane: true, revealIfOpened: false },
     )
   })
 })

+ 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: f4c6da14e5cf3788f2ea0c40902b2dc07558ec7c
-README.zh.md: d96b6dfcaadd02514dc65d74a3a1f266a98f1f3c
+README.md: d0dadc79f552c28c5fea20a23a91c25a24e8ed61
+README.zh.md: 295206ace11baf67112db17b5bda10a52b80d9aa

+ 3 - 1
packages/client/ui-subagent/README.md

@@ -25,7 +25,9 @@ Use this package to browse every subagent conversation beneath a parent session,
 <a id="use-this-package"></a>
 ## Use this package
 
-The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address.
+The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
+
+This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
 
 ### Browsing the tree
 

+ 3 - 1
packages/client/ui-subagent/README.zh.md

@@ -25,7 +25,9 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。
+会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
+
+本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
 
 ### 浏览目录
 

+ 6 - 0
packages/client/ui-subagent/package.json

@@ -30,8 +30,11 @@
       "inject": [
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-resources",
+        "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-primitives",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-input-trigger",
         "@deepseek-ai/dsh-client-ui-workspace"
       ],
@@ -48,7 +51,9 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -64,6 +69,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^"
   },

+ 23 - 0
packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css

@@ -250,6 +250,29 @@
   grid-row: 2;
 }
 
+.sidebarButton {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  margin: 4px 0;
+  padding: 6px;
+  border: 0;
+  border-radius: 50%;
+  corner-shape: round;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.sidebarButton:hover,
+.sidebarButton:focus-visible {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-primary);
+}
+
 .children {
   position: relative;
   margin-left: 18px;

+ 28 - 5
packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx

@@ -9,7 +9,8 @@ import {
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
-  IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
+  IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14,
+  StateDot,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import { NS } from './locales.ts'
@@ -24,6 +25,7 @@ type Catalogs = SessionListState['subagentsByParent']
 /** Business actions supplied by the slot registration. */
 export interface SubagentCatalogInjected {
   openChild: (address: SubagentAddress) => void
+  openChildAside: (address: SubagentAddress) => void
   refresh: (parentSessionId: SessionId) => void
   setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
 }
@@ -42,6 +44,7 @@ interface CatalogRowsProps {
   level: number
   now: number
   openChild: (address: SubagentAddress) => void
+  openChildAside: (address: SubagentAddress) => void
   refresh: (parentSessionId: SessionId) => void
   toggleBranch: (childSessionId: SessionId) => void
   closeCatalog: () => void
@@ -238,7 +241,7 @@ function CatalogLoadingRows({
 /** Render one catalog level and recurse only through explicitly expanded rows. */
 function CatalogRows({
   parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, now,
-  openChild, refresh, toggleBranch, closeCatalog, t,
+  openChild, openChildAside, refresh, toggleBranch, closeCatalog, t,
 }: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
   const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
   const reserveDisclosure = catalog.entries.some(
@@ -327,6 +330,12 @@ function CatalogRows({
           openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
           closeCatalog()
         }
+        const openAside = (event: MouseEvent<HTMLButtonElement>): void => {
+          event.preventDefault()
+          event.stopPropagation()
+          openChildAside({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
+          closeCatalog()
+        }
         const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
           if (event.key === 'Enter' || event.key === ' ') {
             event.preventDefault()
@@ -392,6 +401,18 @@ function CatalogRows({
                     )}
                   </span>
                 )}
+                {!isCurrent && (
+                  <button
+                    type="button"
+                    className={css.sidebarButton}
+                    aria-label={t('open.sidebar', { label })}
+                    title={t('open.sidebar', { label })}
+                    onClick={openAside}
+                    onKeyDown={(event) => { event.stopPropagation() }}
+                  >
+                    <IconChevronRightOutline14 />
+                  </button>
+                )}
               </div>
             </div>
             {isExpanded && !knownLeaf && (
@@ -420,6 +441,7 @@ function CatalogRows({
                       level={level + 1}
                       now={now}
                       openChild={openChild}
+                      openChildAside={openChildAside}
                       refresh={refresh}
                       toggleBranch={toggleBranch}
                       closeCatalog={closeCatalog}
@@ -482,7 +504,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
 /** 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,
+  useSessions, openChild, openChildAside, refresh, setCatalogOpen, t,
 }: CatalogDropdownProps) {
   const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
   const catalogs = useSessions(state => state.subagentsByParent)
@@ -784,6 +806,7 @@ function CatalogDropdown({
             level={1}
             now={now}
             openChild={openChild}
+            openChildAside={openChildAside}
             refresh={refresh}
             toggleBranch={toggleBranch}
             closeCatalog={() => { changeOpen(false) }}
@@ -802,13 +825,13 @@ function CatalogDropdown({
  */
 export function SubagentHeaderLineage({
   lineageSessionId, displayTitle, openTitle,
-  useSessions, openChild, refresh, setCatalogOpen, t,
+  useSessions, openChild, openChildAside, 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 }
+  const shared = { useSessions, openChild, openChildAside, refresh, setCatalogOpen, t }
   if (parentId === undefined) {
     return (
       <CatalogDropdown

+ 14 - 2
packages/client/ui-subagent/src/client/index.ts

@@ -7,9 +7,12 @@ import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentH
 import {
   SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
 } from './SubagentReadOnlyComposer.tsx'
+import { registerSidebarChat, subagentChatAddress } from './sidebar-chat/index.tsx'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import { en, NS, zh, type SubagentKey } from './locales.ts'
 
@@ -27,8 +30,8 @@ export type {
   SubagentReadOnlyComposerProps, SubagentReadOnlyMatch,
 } from './SubagentReadOnlyComposer.tsx'
 
-/** Required services for conversation slots and session navigation. */
-export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale']
+/** Required services for subagent presentation and navigation. */
+export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']
 
 /** Claim the composer for one-shot history or an unavailable continuation owner. */
 function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -51,11 +54,20 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries')
+  ctx.inject(['resources', 'sidebarRightTabs'], (scope) => {
+    registerSidebarChat(scope, ctx.locale.bind(NS))
+  })
   const sessions = ctx.sessions
   const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
     openChild(address: SubagentAddress) {
       ctx.uiWorkspace.openSession(address)
     },
+    openChildAside(address: SubagentAddress) {
+      ctx.sidebarRight.openResource(subagentChatAddress(address), {
+        kind: 'subagentchat',
+        preferNewPane: true,
+      })
+    },
     refresh(parentSessionId: SessionId) {
       void sessions.refreshSubagents(parentSessionId)
     },

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

@@ -38,6 +38,8 @@ export const zh = {
   'count.running.other': '{count} 个子代理,正在运行',
   'switcher.aria': '切换子代理:{title}',
   'tree.aria': '子代理会话',
+  'open.sidebar': '在侧边栏打开 {label}',
+  'sidebar.chat': '聊天',
   'readonly.oneShot.title': '一次性子代理记录',
   'readonly.title': '此子代理暂时只读',
   'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
@@ -79,6 +81,8 @@ export const en: Record<SubagentKey, string> = {
   'count.running.other': '{count} subagents running',
   'switcher.aria': 'Switch subagent: {title}',
   'tree.aria': 'Subagent sessions',
+  'open.sidebar': 'Open {label} in sidebar',
+  'sidebar.chat': 'Chat',
   'readonly.oneShot.title': 'One-shot subagent record',
   'readonly.title': 'This subagent is read-only for now',
   'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.',

+ 7 - 0
packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css

@@ -0,0 +1,7 @@
+.root {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+}

+ 204 - 0
packages/client/ui-subagent/src/client/sidebar-chat/index.tsx

@@ -0,0 +1,204 @@
+/** Right-Sidebar presentation of an existing subagent Conversation. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  ISessions, SessionReference,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
+import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {
+  PropsRenderFactories, PropsRenderSlots, PropsRuntime, TranslateNS,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
+import type { NS } from '../locales.ts'
+import css from './SidebarChat.module.css'
+
+/** Stable implementation identity for the Sidebar tab body. */
+export const SUBAGENT_CHAT_ID = '@deepseek-ai/dsh-client-ui-subagent'
+
+/** Resource-address prefix for an embedded Session chat. */
+export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/'
+
+/** Value retained by one live chat resource occurrence. */
+export interface SubagentChatResource {
+  readonly address: SubagentAddress
+  readonly reference: SessionReference
+}
+
+declare module '@deepseek-ai/dsh-api-session-controller/client' {
+  interface SessionReferenceSourceMap {
+    sidebarChat: unknown
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap {
+    subagentchat: SubagentChatResource
+  }
+
+  interface SlotMap {
+    /** Session-scoped Conversation occurrence hosted by one Sidebar chat tab. */
+    'sidebar.chat.conversation': { kind: 'single'; scope: 'session' }
+  }
+}
+
+/**
+ * Address one subagent Session together with the routing facts needed to restore it.
+ * @param address - durable direct-parent subagent address.
+ * @returns canonical Sidebar resource address.
+ */
+export function subagentChatAddress(address: SubagentAddress): string {
+  const query = new URLSearchParams({
+    parent: address.parentSessionId,
+    mode: address.mode,
+  })
+  return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}`
+}
+
+/**
+ * Parse one canonical Sidebar chat resource address.
+ * @param value - possible chat resource address.
+ * @returns the encoded direct-parent address, or undefined for another or malformed resource.
+ */
+export function parseSubagentChatAddress(value: string): SubagentAddress | undefined {
+  let url: URL
+  try {
+    url = new URL(value)
+  } catch (_invalidUrl) {
+    return undefined
+  }
+  if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'subagentchat') return undefined
+  const parts = url.pathname.split('/').filter(Boolean)
+  if (parts.length !== 2 || parts[0] !== 'session') return undefined
+  const parentSessionId = url.searchParams.get('parent')
+  const mode = url.searchParams.get('mode')
+  if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) {
+    return undefined
+  }
+  try {
+    const childSessionId = decodeURIComponent(parts[1] as string)
+    return {
+      parentSessionId: parentSessionId as SessionId,
+      childSessionId: childSessionId as SessionId,
+      mode,
+    }
+  } catch (_invalidEncoding) {
+    return undefined
+  }
+}
+
+function waitForAbort(signal: AbortSignal): Promise<void> {
+  if (signal.aborted) return Promise.resolve()
+  return new Promise((resolve) => {
+    signal.addEventListener('abort', () => { resolve() }, { once: true })
+  })
+}
+
+function isAbortRequested(signal: AbortSignal): boolean {
+  return signal.aborted
+}
+
+function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'subagentchat'> {
+  return {
+    protocol: 'subagentchat',
+    async *open(resourceAddress, { signal }) {
+      const address = parseSubagentChatAddress(resourceAddress)
+      if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`)
+      if (isAbortRequested(signal)) return
+      await sessions.refreshSubagents(address.parentSessionId)
+      if (isAbortRequested(signal)) return
+      const reference = sessions.retain(address, { source: 'sidebarChat', signal })
+      try {
+        yield { ok: true, value: { address, reference } }
+        await waitForAbort(signal)
+      } finally {
+        reference.release()
+      }
+    },
+  }
+}
+
+/** Fixed Chat selection used by an embedded Conversation occurrence. */
+export function FixedChatConversationView(props: ConversationViewsProps) {
+  return <>{props.renderSlot('conversation.session', { view: 'chat' })}</>
+}
+
+/** Props supplied to the child-Session Conversation host. */
+export type ConversationSlotPanelProps = PropsRuntime<'sidebar.chat.conversation'> & PropsRenderFactories
+
+/** Render the shared Conversation content for one explicitly provided child Session. */
+export function ConversationSlotPanel({
+  sessionId, useSession, useConversation, useSessions, renderFactorySlot,
+}: ConversationSlotPanelProps) {
+  const session = useSession(value => value)
+  const conversation = useConversation(value => value)
+  const active = conversation.activeTargets.size > 0
+    || (!session.blank && !session.awaitingFirstTurn)
+    || session.running
+  const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank'
+  const summaryBlank = useSessions(state => state.byId[sessionId]?.blank)
+  const parentAvailabilityPending = session.subagent?.address.mode === 'continuable'
+    && session.subagent.parentAvailable === undefined
+  const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true)
+    || parentAvailabilityPending
+  const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true)
+  const phase = settling ? 'settling' : hero ? 'hero' : 'active'
+  return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, {
+    slots: { views: FixedChatConversationView },
+  })
+}
+
+/** Props supplied to the parent-Session Sidebar tab body. */
+export type SidebarChatTabProps =
+  PropsRuntime<'sidebar.right.pane.tab'> & PropsRenderSlots<'sidebar.chat.conversation'>
+
+/** Bind a chat resource's child reference around its Conversation slot. */
+export function SidebarChatTab({ useResource, useTabInfo, SessionProvider, renderSlot }: SidebarChatTabProps) {
+  const { tab } = useTabInfo()
+  const resource = useResource<'subagentchat'>(tab.contentId)
+  return (
+    <div className={css.root} data-sidebar-chat="">
+      {resource.value === undefined
+        ? null
+        : (
+          <SessionProvider session={resource.value.reference}>
+            {renderSlot('sidebar.chat.conversation', {})}
+          </SessionProvider>
+        )}
+    </div>
+  )
+}
+
+/**
+ * Register the chat resource owner and its right-Sidebar presentation.
+ * @param ctx - Client root carrying Sessions, resources, Slots, and Sidebar registries.
+ * @param t - Chat namespace translator used for fallback tab titles.
+ */
+export function registerSidebarChat(ctx: Context, t: TranslateNS<typeof NS>): void {
+  ctx.effect(
+    () => ctx.resources.register(subagentChatResourceProvider(ctx.sessions)),
+    'ui-subagent: Sidebar chat resources',
+  )
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id: SUBAGENT_CHAT_ID,
+    kind: 'subagentchat',
+    patterns: [`${SUBAGENT_CHAT_ADDRESS}**`],
+    priority: 'builtin',
+    canOpen: address => parseSubagentChatAddress(address) !== undefined,
+    title: (address) => {
+      const child = parseSubagentChatAddress(address)?.childSessionId
+      return child === undefined
+        ? t('sidebar.chat')
+        : ctx.sessions.list.getSnapshot().byId[child]?.projectionValues?.subagent?.label ?? child
+    },
+  } satisfies SidebarRightTabDefinition), 'ui-subagent: Sidebar chat type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab',
+    key: SUBAGENT_CHAT_ID,
+    children: { 'sidebar.chat.conversation': { kind: 'single', scope: 'session' } },
+  }, SidebarChatTab)), 'ui-subagent: Sidebar chat body')
+  ctx.effect(() => ctx.slots.inject('sidebar.chat.conversation', () => ctx.slots.register({
+    name: 'sidebar.chat.conversation',
+  }, ConversationSlotPanel)), 'ui-subagent: Sidebar Conversation')
+}

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

@@ -72,6 +72,11 @@ async function fullBench(sessions: SessionSummary[]) {
       face.actionCalls.push({ method: 'openSession', args: [address] })
     },
   } as never)
+  ctx.provide('sidebarRight', {
+    openResource: (address: string, options: unknown) => {
+      face.actionCalls.push({ method: 'openResource', args: [address, options] })
+    },
+  } as never)
   ctx.provide('remote', { $on: () => () => {} } as never)
   ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   await provideSlotFaces(ctx)
@@ -92,7 +97,7 @@ const FAMILY: SessionSummary[] = [
 
 describe('apply', () => {
   it('declares the services it binds', () => {
-    expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale'])
+    expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'])
   })
 
   it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
@@ -106,10 +111,18 @@ describe('apply', () => {
       mode: 'continuable',
     }
     actions.openChild(address)
+    actions.openChildAside(address)
     actions.refresh(sid('parent'))
     actions.setCatalogOpen(sid('parent'), true)
     expect(face.actionCalls).toEqual([
       { method: 'openSession', args: [address] },
+      {
+        method: 'openResource',
+        args: [
+          'dsh-resource://subagentchat/session/c1?parent=parent&mode=continuable',
+          { kind: 'subagentchat', preferNewPane: true },
+        ],
+      },
       { method: 'refreshSubagents', args: [sid('parent')] },
       { method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
     ])

+ 9 - 0
packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx

@@ -72,6 +72,7 @@ function props(
     sessionId: PARENT,
     useSessions,
     openChild: vi.fn(),
+    openChildAside: vi.fn(),
     refresh: vi.fn(),
     setCatalogOpen: vi.fn(),
     lineageSessionId: PARENT,
@@ -164,6 +165,14 @@ describe('SubagentHeaderLineage', () => {
     expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
     expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2)
 
+    const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' })
+    fireEvent.keyDown(sidebarButton, { key: 'Enter' })
+    fireEvent.click(sidebarButton)
+    expect(input.openChildAside).toHaveBeenCalledWith({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
+    })
+
+    hoverCatalog(trigger)
     fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
     expect(input.openChild).toHaveBeenCalledWith({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',

+ 237 - 0
packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx

@@ -0,0 +1,237 @@
+// @vitest-environment jsdom
+import type { ReactNode } from 'react'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  ISessions, SessionListState, SessionReference, SessionSnapshot,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
+import { sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime'
+import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
+import {
+  ConversationSlotPanel, FixedChatConversationView, parseSubagentChatAddress,
+  registerSidebarChat, subagentChatAddress, SUBAGENT_CHAT_ID, type SidebarChatTabProps,
+} from '../src/client/sidebar-chat/index.tsx'
+import { SidebarChatTab } from '../src/client/sidebar-chat/index.tsx'
+
+const PARENT = 'parent/a' as SessionId
+const CHILD = 'child#1' as SessionId
+const ADDRESS: SubagentAddress = {
+  parentSessionId: PARENT,
+  childSessionId: CHILD,
+  mode: 'continuable',
+}
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+})
+
+describe('Sidebar chat address', () => {
+  it('round-trips the child identity and direct-parent routing metadata', () => {
+    const resource = subagentChatAddress(ADDRESS)
+    expect(resource).toBe('dsh-resource://subagentchat/session/child%231?parent=parent%2Fa&mode=continuable')
+    expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS)
+    expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' })))
+      .toEqual({ ...ADDRESS, mode: 'one-shot' })
+  })
+
+  it.each([
+    'not an address',
+    'https://chat/session/child?parent=parent&mode=continuable',
+    'dsh-resource://other/session/child?parent=parent&mode=continuable',
+    'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable',
+    'dsh-resource://subagentchat/session/child?mode=continuable',
+    'dsh-resource://subagentchat/session/child?parent=&mode=continuable',
+    'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown',
+    'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable',
+  ])('rejects %s', (address) => {
+    expect(parseSubagentChatAddress(address)).toBeUndefined()
+  })
+})
+
+describe('Sidebar chat registration', () => {
+  it('registers the resource, tab, and child slot and retains the child for the resource lifetime', async () => {
+    const release = vi.fn()
+    const reference = {
+      sessionId: CHILD,
+      ready: Promise.resolve(undefined),
+      release,
+      [Symbol.dispose]: release,
+    } as unknown as SessionReference
+    const retain = vi.fn(() => reference)
+    const refreshSubagents = vi.fn(() => Promise.resolve())
+    const list = {
+      getSnapshot: () => ({
+        ids: [],
+        byId: {
+          [CHILD]: {
+            displayTitle: 'Projected title',
+            projectionValues: { subagent: { mode: 'continuable', label: 'Worker', seq: 1 } },
+          },
+        },
+        phase: 'ready',
+        subagentsByParent: {},
+        jobsBySession: {},
+      } as unknown as SessionListState),
+      subscribe: () => () => {},
+    }
+    let provider: ResourceProvider<'subagentchat'> | undefined
+    let definition: SidebarRightTabDefinition | undefined
+    const registrations: { options: Record<string, unknown>; component: unknown }[] = []
+    const ctx = {
+      sessions: { retain, refreshSubagents, list } as unknown as ISessions,
+      resources: { register: (value: ResourceProvider<'subagentchat'>) => { provider = value; return () => {} } },
+      sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } },
+      slots: {
+        inject: (_name: string, install: () => () => void) => install(),
+        register: (options: Record<string, unknown>, component: unknown) => {
+          registrations.push({ options, component })
+          return () => {}
+        },
+      },
+      effect: (install: () => unknown) => { install(); return () => {} },
+    } as unknown as Context
+
+    registerSidebarChat(ctx, (key: string) => key === 'sidebar.chat' ? 'Chat' : key)
+
+    expect(definition?.id).toBe(SUBAGENT_CHAT_ID)
+    expect(definition?.kind).toBe('subagentchat')
+    expect(definition?.canOpen?.(subagentChatAddress(ADDRESS))).toBe(true)
+    expect(definition?.canOpen?.('dsh-resource://subagentchat/invalid')).toBe(false)
+    expect(definition?.title(subagentChatAddress(ADDRESS))).toBe('Worker')
+    expect(definition?.title(subagentChatAddress({ ...ADDRESS, childSessionId: 'unknown' as SessionId }))).toBe('unknown')
+    expect(definition?.title('invalid')).toBe('Chat')
+    expect(registrations.map(entry => entry.options.name)).toEqual([
+      'sidebar.right.pane.tab',
+      'sidebar.chat.conversation',
+    ])
+
+    const controller = new AbortController()
+    const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]()
+    expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } })
+    expect(refreshSubagents).toHaveBeenCalledWith(PARENT)
+    expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal })
+    const completion = stream.next()
+    await Promise.resolve()
+    controller.abort()
+    expect(await completion).toEqual({ done: true, value: undefined })
+    expect(release).toHaveBeenCalledOnce()
+
+    const abortedAfterYield = new AbortController()
+    const yielded = provider!.open(subagentChatAddress(ADDRESS), { signal: abortedAfterYield.signal })[Symbol.asyncIterator]()
+    expect((await yielded.next()).done).toBe(false)
+    abortedAfterYield.abort()
+    expect(await yielded.next()).toEqual({ done: true, value: undefined })
+    expect(release).toHaveBeenCalledTimes(2)
+
+    let finishRefresh: (() => void) | undefined
+    refreshSubagents.mockImplementationOnce(() => new Promise<void>((resolve) => { finishRefresh = resolve }))
+    const abortedDuringRefresh = new AbortController()
+    const pending = provider!.open(
+      subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal },
+    )[Symbol.asyncIterator]().next()
+    await vi.waitFor(() => { expect(finishRefresh).toBeTypeOf('function') })
+    abortedDuringRefresh.abort()
+    finishRefresh?.()
+    expect(await pending).toEqual({ done: true, value: undefined })
+
+    const alreadyAborted = new AbortController()
+    alreadyAborted.abort()
+    const stopped = provider!.open(subagentChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]()
+    expect(await stopped.next()).toEqual({ done: true, value: undefined })
+    expect(retain).toHaveBeenCalledTimes(2)
+    expect(release).toHaveBeenCalledTimes(2)
+
+    const invalid = provider!.open('invalid', { signal: new AbortController().signal })[Symbol.asyncIterator]()
+    await expect(invalid.next()).rejects.toThrow('invalid chat resource address')
+  })
+})
+
+describe('Sidebar chat components', () => {
+  it('binds a live resource reference around the child Session slot', () => {
+    const reference = { sessionId: CHILD } as SessionReference
+    let snapshot = { status: 'loading', value: undefined, failure: undefined } as const
+    const SessionProvider = vi.fn(({ children }: { children: ReactNode }) => <>{children}</>)
+    const renderSlot = vi.fn(() => <span>child conversation</span>)
+    const props = {
+      useTabInfo: () => ({ tab: { contentId: subagentChatAddress(ADDRESS) } }),
+      useResource: () => snapshot,
+      SessionProvider,
+      renderSlot,
+    } as unknown as SidebarChatTabProps
+    const view = render(<SidebarChatTab {...props} />)
+    expect(view.container.textContent).toBe('')
+
+    snapshot = { status: 'live', value: { address: ADDRESS, reference }, failure: undefined } as never
+    view.rerender(<SidebarChatTab {...props} />)
+    expect(view.getByText('child conversation')).toBeTruthy()
+    expect(SessionProvider).toHaveBeenCalledWith(expect.objectContaining({ session: reference }), {})
+    expect(renderSlot).toHaveBeenCalledWith('sidebar.chat.conversation', {})
+  })
+
+  it.each([
+    {
+      name: 'active',
+      session: { blank: false, awaitingFirstTurn: false, running: false },
+      summaryBlank: false,
+      expected: { phase: 'active', hero: false },
+    },
+    {
+      name: 'hero',
+      session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open' },
+      summaryBlank: true,
+      expected: { phase: 'hero', hero: true },
+    },
+    {
+      name: 'prompt attempted',
+      session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open', promptAttempted: true },
+      summaryBlank: false,
+      expected: { phase: 'active', hero: false },
+    },
+    {
+      name: 'loading',
+      session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'loading' },
+      summaryBlank: false,
+      expected: { phase: 'settling', hero: false },
+    },
+    {
+      name: 'continuable parent discovery',
+      session: {
+        blank: false,
+        awaitingFirstTurn: false,
+        running: false,
+        subagent: { address: ADDRESS, parentAvailable: undefined },
+      },
+      summaryBlank: false,
+      expected: { phase: 'settling', hero: false },
+    },
+  ])('derives the $name embedded phase and fixes the local view to Chat', ({ session: patch, summaryBlank, expected }) => {
+    const snapshot = { ...sessionSnapshot(CHILD), ...patch } as SessionSnapshot
+    const renderFactorySlot = vi.fn(() => null)
+    render(<ConversationSlotPanel {...({
+      sessionId: CHILD,
+      useSession: (select: (value: SessionSnapshot) => unknown) => select(snapshot),
+      useConversation: (select: (value: { activeTargets: ReadonlySet<string> }) => unknown) => select({ activeTargets: new Set() }),
+      useSessions: (select: (value: SessionListState) => unknown) => select({
+        ids: [], byId: { [CHILD]: { blank: summaryBlank } }, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
+      } as unknown as SessionListState),
+      renderFactorySlot,
+    } as unknown as Parameters<typeof ConversationSlotPanel>[0])} />)
+    expect(renderFactorySlot).toHaveBeenCalledWith(
+      'conversation.content',
+      { variant: 'embedded', ...expected },
+      { slots: { views: FixedChatConversationView } },
+    )
+  })
+
+  it('routes the fixed local view through the ordinary Conversation Session slot', () => {
+    const renderSlot = vi.fn(() => null)
+    render(<FixedChatConversationView {...({ renderSlot } as unknown as ConversationViewsProps)} />)
+    expect(renderSlot).toHaveBeenCalledWith('conversation.session', { view: 'chat' })
+  })
+})

+ 9 - 0
packages/client/ui-subagent/tsconfig.json

@@ -20,6 +20,12 @@
     {
       "path": "../locale"
     },
+    {
+      "path": "../resources"
+    },
+    {
+      "path": "../ui-chat"
+    },
     {
       "path": "../ui-conversation"
     },
@@ -32,6 +38,9 @@
     {
       "path": "../ui-renderer"
     },
+    {
+      "path": "../ui-sidebar-right"
+    },
     {
       "path": "../ui-session"
     },

+ 2 - 2
packages/context/session-reference/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/context/session-reference/README.md
-README.md: 533a1282da920b6daa47ef5be98e49c691a8cb84
-README.zh.md: e7b43c9a56cd5b4062770144ccea0bf125153b40
+README.md: e1afeef9b0e78826705ad2b3e426373b2bf0833d
+README.zh.md: e55dab4f6403cb6aee947f80873ffe99919acc5f

+ 1 - 1
packages/context/session-reference/README.md

@@ -39,7 +39,7 @@ For a truncated reference, an optional spill backend saves the full captured tex
 
 ### Finding sessions to reference
 
-`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention.
+`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, projected title, or display title, and ranks same-directory sessions first. Each candidate carries its latest title as `label`, falling back to the session id when the title is absent or unreadable. Its display title prefers a subagent's durable creation label over that title. The candidate also reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which labels the canonical mention with `displayTitle` when present.
 
 ### Configuration
 

+ 1 - 1
packages/context/session-reference/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 
 ### 查找可引用的会话
 
-`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
+`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选的 `label` 使用最新标题;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建 label,再回退到该标题。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力;该方法在 `displayTitle` 存在时用它标记规范 mention。
 
 ### 配置
 

+ 2 - 0
packages/context/session-reference/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-spill": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   },
@@ -88,6 +89,7 @@
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-spill": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-spill-local": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",

+ 33 - 24
packages/context/session-reference/src/index.ts

@@ -18,6 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-session-projection-cache'
 import type {} from '@deepseek-ai/dsh-session-title'
+import type {} from '@deepseek-ai/dsh-subagent'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
 import { prepareReferenceOmission, REFERENCE_WARNING } from './spill.ts'
@@ -179,14 +180,13 @@ export class SessionReferenceResolver extends TypertRemoteService {
   /**
    * List reference candidates, ranked by working-directory affinity.
    *
-   * Discovery runs at keystroke rate, so a title only ever comes from a
-   * projection read: see {@link SessionReferenceResolver.projectedTitle} for
-   * which sessions can answer one and which fall back to their id.
+   * Discovery runs at keystroke rate, so titles and subagent labels only ever
+   * come from projection reads; sessions without either fall back to their id.
    * @param agent - target agent; self is excluded and its cwd drives ranking.
-   * @param query - optional case-insensitive session-id/cwd/title substring.
+   * @param query - optional case-insensitive session-id/cwd/title/display-title substring.
    * @param limit - optional positive result cap.
    * @param signal - optional cancellation boundary for host autocomplete teardown.
-   * @returns candidates labeled by latest title or, when absent, session id.
+   * @returns candidates with canonical mention labels and presentation titles.
    */
   async listCandidates(
     agent: Agent,
@@ -203,22 +203,20 @@ export class SessionReferenceResolver extends TypertRemoteService {
     const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
       .filter(record => record.header.id !== agent.id)
       .map((record, index) => ({ record, index }))
-    const labelled = records.map(({ record, index }) => ({
-      record,
-      index,
-      label: this.projectedTitle(record) ?? record.header.id,
-    }))
-    return labelled.filter(({ record, label }) => {
+    const labelled = records.map(({ record, index }) => ({ record, index, ...this.projectedLabels(record) }))
+    return labelled.filter(({ record, label, displayTitle }) => {
       if (needle === '') return true
       return record.header.id.toLocaleLowerCase().includes(needle)
         || record.header.cwd?.toLocaleLowerCase().includes(needle) === true
         || label.toLocaleLowerCase().includes(needle)
+        || displayTitle.toLocaleLowerCase().includes(needle)
     }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
       || a.index - b.index)
       .slice(0, limit)
-      .map(({ record, label }) => ({
+      .map(({ record, label, displayTitle }) => ({
         sessionId: record.header.id,
         label,
+        displayTitle,
         ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
         sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd,
         createdAt: record.header.createdAt,
@@ -226,7 +224,7 @@ export class SessionReferenceResolver extends TypertRemoteService {
   }
 
   /**
-   * The title a session's projections can answer without reading its log.
+   * The mention label and display title a Session's projections can answer without reading its log.
    *
    * Attachment is decided by the store at read time, not by the listing:
    * a session that attached in between would otherwise be answered from a
@@ -245,20 +243,28 @@ export class SessionReferenceResolver extends TypertRemoteService {
    * and cannot be found by its title until it is opened once, which
    * checkpoints it.
    * @param record - the listed session, live or cold.
-   * @returns the projected title, or undefined when no projection holds one.
+   * @returns the title-backed mention label and the subagent-label-first display title.
    */
-  private projectedTitle(record: SessionRecord): string | undefined {
+  private projectedLabels(record: SessionRecord): { label: string; displayTitle: string } {
     const attached = this.ctx.get('sessions')?.get(record.header.id)
     const projections = this.ctx.get('sessionProjections')
-    if (attached !== undefined && projections !== undefined) {
-      return titleOf(projections.snapshot(attached, ['title']))
+    const snapshot = attached !== undefined && projections !== undefined
+      ? projections.snapshot(attached, ['title', 'subagent'])
+      : record.header.isSeeded
+        ? undefined
+        : this.ctx.get('sessionProjectionCache')?.cachedSnapshot(
+          record.header,
+          SessionLogOffset(0),
+          ['title', 'subagent'],
+        )
+    const label = titleOf(snapshot) ?? record.header.id
+    const subagent = snapshot?.values.subagent
+    return {
+      label,
+      displayTitle: subagent === undefined || subagent === null
+        ? label
+        : subagent.label ?? label,
     }
-    if (record.header.isSeeded) return undefined
-    return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(
-      record.header,
-      SessionLogOffset(0),
-      ['title'],
-    ))
   }
 
   /**
@@ -279,7 +285,10 @@ export class SessionReferenceResolver extends TypertRemoteService {
     const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal)
     return candidates.map(candidate => ({
       ...candidate,
-      mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }),
+      mention: formatSessionReferenceMention({
+        sessionId: candidate.sessionId,
+        label: candidate.displayTitle ?? candidate.label,
+      }),
     }))
   }
 

+ 2 - 0
packages/context/session-reference/src/types.ts

@@ -51,6 +51,8 @@ export interface SessionReferenceCandidate {
   sessionId: SessionId
   /** Latest log-backed title, falling back to the opaque session id. */
   label: string
+  /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */
+  displayTitle?: string
   /** Source session working directory, when recorded. */
   cwd?: string
   /**

+ 97 - 15
packages/context/session-reference/tests/session-reference.spec.ts

@@ -54,11 +54,26 @@ async function harness(config: Config = {}): Promise<Context> {
  * resolver reads `cachedSnapshot` alone, and the point under test is which
  * sessions still reach a log fold.
  */
-function withProjectionCache(ctx: Context, rows: Record<string, string | null>): void {
+function withProjectionCache(
+  ctx: Context,
+  rows: Record<string, string | null | {
+    title?: string | null
+    subagent?: {
+      readonly mode: 'one-shot' | 'continuable'
+      readonly label?: string
+      readonly seq: SessionSeq
+    } | null
+  }>,
+): void {
   ctx.provide('sessionProjectionCache', {
-    cachedSnapshot: (meta: { id: SessionId }) => (
-      meta.id in rows ? { asOfSeq: SessionSeq(0), values: { title: rows[meta.id] } } : undefined
-    ),
+    cachedSnapshot: (meta: { id: SessionId }) => {
+      const row = rows[meta.id]
+      if (!(meta.id in rows)) return undefined
+      return {
+        asOfSeq: SessionSeq(0),
+        values: row !== null && typeof row === 'object' ? row : { title: row },
+      }
+    },
   })
 }
 
@@ -633,16 +648,16 @@ describe('session reference discovery and preparation', () => {
     })
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
-      { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
-      { sessionId: SessionId('same'), label: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 },
-      { sessionId: SessionId('none'), label: 'none', sameWorkspace: false, createdAt: 30 },
-      { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
+      { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
+      { sessionId: SessionId('same'), label: 'same', displayTitle: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 },
+      { sessionId: SessionId('none'), label: 'none', displayTitle: 'none', sameWorkspace: false, createdAt: 30 },
+      { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
     ])
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
-      { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
+      { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
     ])
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
-      { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
+      { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
     ])
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0))
       .rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
@@ -675,7 +690,7 @@ describe('session reference discovery and preparation', () => {
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed'))
       .resolves.toEqual([
-        { sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt },
+        { sessionId: live.id, label: 'Renamed mid turn', displayTitle: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt },
       ])
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([])
     expect(readTitles).not.toHaveBeenCalled()
@@ -694,12 +709,66 @@ describe('session reference discovery and preparation', () => {
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint'))
       .resolves.toEqual([
-        { sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 },
+        { sessionId: cold.id, label: 'Cold checkpoint', displayTitle: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 },
       ])
     expect(readTitles).not.toHaveBeenCalled()
     vi.restoreAllMocks()
   })
 
+  it('keeps the projected title as the mention label and prefers the subagent label for display', async () => {
+    const ctx = await harness()
+    const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
+    const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' }
+    withProjectionCache(ctx, {
+      child: {
+        title: 'Investigate startup',
+        subagent: { mode: 'continuable', label: 'researcher', seq: SessionSeq(2) },
+      },
+    })
+    vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
+      { header: child, live: false, persisted: true },
+    ] as never)
+
+    await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'researcher'))
+      .resolves.toEqual([{
+        sessionId: child.id,
+        label: 'Investigate startup',
+        displayTitle: 'researcher',
+        cwd: '/same',
+        sameWorkspace: true,
+        createdAt: 10,
+      }])
+    await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'investigate'))
+      .resolves.toHaveLength(1)
+    await expect(ctx.sessionReferenceResolver.remoteExportCandidates(
+      fakeAgent(target), 'researcher', new AbortController().signal,
+    )).resolves.toEqual([expect.objectContaining({
+      mention: formatSessionReferenceMention({ sessionId: child.id, label: 'researcher' }),
+    })])
+  })
+
+  it('falls back to the projected title when a subagent projection has no label', async () => {
+    const ctx = await harness()
+    const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
+    const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' }
+    withProjectionCache(ctx, {
+      child: {
+        title: 'Projected title',
+        subagent: { mode: 'continuable', seq: SessionSeq(2) },
+      },
+    })
+    vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
+      { header: child, live: false, persisted: true },
+    ] as never)
+
+    await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'child'))
+      .resolves.toEqual([expect.objectContaining({
+        sessionId: child.id,
+        label: 'Projected title',
+        displayTitle: 'Projected title',
+      })])
+  })
+
   it('labels a session no projection answers for by its id, still without a log read', async () => {
     const ctx = await harness()
     const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
@@ -718,7 +787,7 @@ describe('session reference discovery and preparation', () => {
     const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
-      { sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 },
+      { sessionId: seeded.id, label: seeded.id, displayTitle: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 },
     ])
     // Its own title cannot find it, and discovery still never opens the log.
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([])
@@ -736,7 +805,7 @@ describe('session reference discovery and preparation', () => {
     other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } })
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
-      { sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt },
+      { sessionId: other.id, label: other.id, displayTitle: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt },
     ])
   })
 
@@ -752,11 +821,24 @@ describe('session reference discovery and preparation', () => {
     expect(candidates).toEqual([{
       sessionId: SessionId('source]'),
       label: 'source]',
+      displayTitle: 'source]',
       cwd: '/same',
       sameWorkspace: true,
       createdAt: 20,
       mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }),
     }])
+
+    vi.spyOn(ctx.sessionReferenceResolver, 'listCandidates').mockResolvedValueOnce([{
+      sessionId: SessionId('fallback'),
+      label: 'Fallback title',
+      sameWorkspace: false,
+      createdAt: 30,
+    }])
+    await expect(ctx.sessionReferenceResolver.remoteExportCandidates(
+      fakeAgent(target), '', new AbortController().signal,
+    )).resolves.toEqual([expect.objectContaining({
+      mention: formatSessionReferenceMention({ sessionId: SessionId('fallback'), label: 'Fallback title' }),
+    })])
   })
 
   it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => {
@@ -842,7 +924,7 @@ describe('session reference discovery and preparation', () => {
     const source = ctx.sessions.create(SessionId('source'))
 
     await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
-      { sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt },
+      { sessionId: source.id, label: source.id, displayTitle: source.id, sameWorkspace: false, createdAt: source.header.createdAt },
     ])
   })
 

+ 3 - 0
packages/context/session-reference/tsconfig.json

@@ -49,6 +49,9 @@
     },
     {
       "path": "../../session/session-title"
+    },
+    {
+      "path": "../../subagent/subagent"
     }
   ]
 }

+ 59 - 21
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -406,7 +406,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:176',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180',
   },
   {
     key: 'conversation.composer.bar',
@@ -445,7 +445,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:194',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198',
   },
   {
     key: 'conversation.composer.dock',
@@ -501,7 +501,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:188',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -538,7 +538,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:182',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -567,7 +567,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     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:180',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
   },
   {
     key: 'conversation.hero.workspace',
@@ -600,7 +600,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:178',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -673,7 +673,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:196',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200',
   },
   {
     key: 'conversation.input.dock',
@@ -736,7 +736,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:184',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
   },
   {
     key: 'conversation.input.left',
@@ -790,7 +790,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:190',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194',
   },
   {
     key: 'conversation.input.model',
@@ -829,7 +829,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:206',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:210',
   },
   {
     key: 'conversation.input.overlay',
@@ -887,7 +887,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', 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:186',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190',
   },
   {
     key: 'conversation.input.permission',
@@ -926,7 +926,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.permission\', () => ctx.slots.register(\n      { name: \'conversation.input.permission\' },\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:208',
   },
   {
     key: 'conversation.input.plan',
@@ -965,7 +965,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:202',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
   },
   {
     key: 'conversation.input.right',
@@ -1019,7 +1019,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:192',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
   },
   {
     key: 'conversation.message.images',
@@ -1194,7 +1194,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.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1256,7 +1256,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.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:139',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143',
   },
   {
     key: 'conversation.session.header.corner',
@@ -1295,7 +1295,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.session.header.corner\', () => ctx.slots.register(\n      { name: \'conversation.session.header.corner\' },\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:172',
   },
   {
     key: 'conversation.session.header.leading',
@@ -1334,7 +1334,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.session.header.leading\', () => ctx.slots.register(\n      { name: \'conversation.session.header.leading\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1375,7 +1375,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.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:133',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1434,7 +1434,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:145',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
   },
   {
     key: 'conversation.trajectory.images',
@@ -1538,7 +1538,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:174',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
   },
   {
     key: 'main',
@@ -2418,6 +2418,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n      { name: \'sidebar.brand.name\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:29',
   },
+  {
+    key: 'sidebar.chat.conversation',
+    kind: 'single',
+    scope: 'session',
+    summary: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.',
+    doc: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.',
+    registerOptions: [],
+    ownerProps: [],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useResource: UseResource',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionStatus: UseSessionStatus',
+      'useSessionRetainInfo: UseSessionRetainInfo',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-subagent), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-subagent ConversationSlotPanel',
+    ],
+    replaceRisk: 'shadows-shipped-ui',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.chat.conversation\', () => ctx.slots.register(\n      { name: \'sidebar.chat.conversation\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-subagent/src/client/sidebar-chat/index.tsx:42',
+  },
   {
     key: 'sidebar.footer.action',
     kind: 'list',
@@ -2563,6 +2600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-sidebar-files FilesBody',
       'client-ui-sidebar-right GuideBody',
       'client-ui-sidebar-terminal LazyTerminalBody',
+      'client-ui-subagent SidebarChatTab',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n      { name: \'sidebar.right.pane.tab\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',

+ 4 - 4
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2065,9 +2065,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       {
         signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
-        description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.',
-        parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }],
-        returns: 'candidates labeled by latest title or, when absent, session id.',
+        description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so titles and subagent labels only ever come from projection reads; sessions without either fall back to their id.',
+        parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title/display-title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }],
+        returns: 'candidates with canonical mention labels and presentation titles.',
       },
       {
         signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise<SessionReferenceMentionCandidate[]>',
@@ -5994,7 +5994,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionReferenceCandidate',
-    declaration: 'export interface SessionReferenceCandidate {\n    sessionId: SessionId;\n    label: string;\n    cwd?: string;\n    sameWorkspace: boolean;\n    createdAt: number;\n}',
+    declaration: 'export interface SessionReferenceCandidate {\n    sessionId: SessionId;\n    label: string;\n    displayTitle?: string;\n    cwd?: string;\n    sameWorkspace: boolean;\n    createdAt: number;\n}',
   },
   {
     name: 'SessionReferenceInput',

+ 15 - 0
pnpm-lock.yaml

@@ -2808,6 +2808,9 @@ importers:
       '@deepseek-ai/dsh-settings':
         specifier: workspace:^
         version: link:../../settings/settings
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
       '@deepseek-ai/dsh-token-meter':
         specifier: workspace:^
         version: link:../../llm/token-meter
@@ -4762,9 +4765,15 @@ importers:
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-resources':
+        specifier: workspace:^
+        version: link:../resources
       '@deepseek-ai/dsh-client-test-runtime':
         specifier: workspace:^
         version: link:../../test-support/client-runtime
+      '@deepseek-ai/dsh-client-ui-chat':
+        specifier: workspace:^
+        version: link:../ui-chat
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
@@ -4780,6 +4789,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-session':
         specifier: workspace:^
         version: link:../ui-session
+      '@deepseek-ai/dsh-client-ui-sidebar-right':
+        specifier: workspace:^
+        version: link:../ui-sidebar-right
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
@@ -5603,6 +5615,9 @@ importers:
       '@deepseek-ai/dsh-spill-local':
         specifier: workspace:^
         version: link:../../spill/spill-local
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt

+ 4 - 1
snapshots/web/subagent-conversation/branchless.expected.md

@@ -1,2 +1,5 @@
 - tree "Subagent sessions":
-  - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}}
+  - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]:
+    - text: example editor continuable · not running 0 tok {{duration}}
+    - button "Open example editor in sidebar":
+      - img

+ 35 - 0
snapshots/web/subagent-conversation/sidebar-chat.expected.md

@@ -0,0 +1,35 @@
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}"
+- button "Copy":
+  - img
+- button "Thought for a while":
+  - text: Thought for a while
+  - img
+- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- textbox "Message or run a task, / commands, @ files or sessions"
+- button "Add files or run commands":
+  - img
+- 'button "Access mode, current: Custom"': Custom
+- button "Send message" [disabled]
+- button "1 turns 1 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 1 steps{{throughput}} tok/s
+- button "7.9K tok · Cache hit 99%":
+  - img
+  - text: 7.9K tokCache hit 99%
+- button "6% of context used": 6%

+ 10 - 2
snapshots/web/subagent-conversation/tree.expected.md

@@ -1,8 +1,16 @@
 - tree "Subagent sessions":
-  - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
+  - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]:
+    - text: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
+    - button "Open event-sourcing reviewer in sidebar":
+      - img
   - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
     - button "Collapse event-sourcing researcher descendants":
       - img
     - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}}
+    - button "Open event-sourcing researcher in sidebar":
+      - img
   - group:
-    - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}
+    - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]:
+      - text: example editor continuable · not running 0 tok {{duration}}
+      - button "Open example editor in sidebar":
+        - img