Przeglądaj źródła

feat(client): open subagent chats in sidebar

imccyu 2 tygodni temu
rodzic
commit
e62587c163
62 zmienionych plików z 1257 dodań i 414 usunięć
  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. 24 1
      apps/web/tests/subagent-conversation.e2e.ts
  5. 2 2
      docs/subsystems/session-reference.i18n.yaml
  6. 8 7
      docs/subsystems/session-reference.md
  7. 8 7
      docs/subsystems/session-reference.zh.md
  8. 10 0
      packages/api/session-controller/src/client/sessions/manager.ts
  9. 12 3
      packages/api/session-controller/src/client/sessions/service.ts
  10. 18 4
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  11. 3 0
      packages/client/ui-chat/package.json
  12. 3 1
      packages/client/ui-chat/src/client/apply.ts
  13. 7 0
      packages/client/ui-chat/src/client/index.ts
  14. 7 0
      packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css
  15. 197 0
      packages/client/ui-chat/src/client/sidebar-chat/index.tsx
  16. 2 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  17. 2 0
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  18. 205 0
      packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx
  19. 6 0
      packages/client/ui-chat/tsconfig.json
  20. 4 1
      packages/client/ui-conversation/src/client/apply.ts
  21. 20 6
      packages/client/ui-conversation/src/client/contract/slots.ts
  22. 1 0
      packages/client/ui-conversation/src/client/index.ts
  23. 20 131
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  24. 5 93
      packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
  25. 27 30
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  26. 175 0
      packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx
  27. 4 3
      packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx
  28. 19 15
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  29. 2 2
      packages/client/ui-reference/README.i18n.yaml
  30. 1 1
      packages/client/ui-reference/README.md
  31. 1 1
      packages/client/ui-reference/README.zh.md
  32. 1 0
      packages/client/ui-reference/package.json
  33. 23 10
      packages/client/ui-reference/src/client/index.ts
  34. 2 0
      packages/client/ui-reference/src/client/locales.ts
  35. 63 1
      packages/client/ui-reference/tests/browser-plugin.client.spec.ts
  36. 3 0
      packages/client/ui-reference/tsconfig.json
  37. 2 0
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  38. 18 1
      packages/client/ui-sidebar-right/src/client/service.ts
  39. 26 12
      packages/client/ui-sidebar-right/src/client/stores.ts
  40. 1 0
      packages/client/ui-sidebar-right/src/client/tab-domain.ts
  41. 33 0
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  42. 4 0
      packages/client/ui-subagent/package.json
  43. 22 0
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
  44. 28 5
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
  45. 14 2
      packages/client/ui-subagent/src/client/index.ts
  46. 2 0
      packages/client/ui-subagent/src/client/locales.ts
  47. 14 1
      packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
  48. 9 0
      packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
  49. 3 0
      packages/client/ui-subagent/tsconfig.json
  50. 2 2
      packages/context/session-reference/README.i18n.yaml
  51. 1 1
      packages/context/session-reference/README.md
  52. 1 1
      packages/context/session-reference/README.zh.md
  53. 5 0
      packages/context/session-reference/package.json
  54. 25 24
      packages/context/session-reference/src/index.ts
  55. 3 1
      packages/context/session-reference/src/types.ts
  56. 55 15
      packages/context/session-reference/tests/session-reference.spec.ts
  57. 3 0
      packages/context/session-reference/tsconfig.json
  58. 59 21
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  59. 4 4
      packages/extensions/tool-cordis/src/api-catalog.ts
  60. 18 0
      pnpm-lock.yaml
  61. 4 1
      snapshots/web/subagent-conversation/branchless.expected.md
  62. 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 授权。

+ 24 - 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))
@@ -34,6 +34,7 @@ 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 +98,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 +147,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 +304,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'

+ 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: 0d3cd061f4d787772312e97e365d9a24ca6dd492
+session-reference.zh.md: 9537096eec7453d7bdbcd6e39458fac99e61bf8c

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

@@ -34,15 +34,17 @@ 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 mention 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.
 
 ```ts type-equiv
 /** One host-facing candidate from exact session metadata. */
 interface SessionReferenceCandidate {
   /** Opaque source session identity. */
   sessionId: SessionId
-  /** Latest log-backed title, falling back to the opaque session id. */
+  /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */
   label: string
+  /** Display 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[]>
 

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

@@ -34,15 +34,17 @@ interface SessionReferenceInput {
 }
 ```
 
-`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。
+`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 mention label 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。
 
 ```ts type-equiv
 /** One host-facing candidate from exact session metadata. */
 interface SessionReferenceCandidate {
   /** Opaque source session identity. */
   sessionId: SessionId
-  /** Latest log-backed title, falling back to the opaque session id. */
+  /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */
   label: string
+  /** Display 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[]>
 

+ 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')
   })
 

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

@@ -31,6 +31,7 @@
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-api-workspace-controller",
         "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-resources",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-input-trigger",
         "@deepseek-ai/dsh-client-ui-layout",
@@ -60,6 +61,7 @@
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-approval": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -82,6 +84,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:^",

+ 3 - 1
packages/client/ui-chat/src/client/apply.ts

@@ -36,6 +36,7 @@ import { createChatStore } from './stores.ts'
 import { TranscriptViewPolicy } from './transcript-view.ts'
 import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
 import { useTurnDataValue } from './chat/use-turn-data.ts'
+import { registerSidebarChat } from './sidebar-chat/index.tsx'
 
 const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
   hooks: {
@@ -48,7 +49,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
   'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
-  'settingsScope', 'remote', 'remote.session', 'sidebarRight',
+  'settingsScope', 'remote', 'remote.session', 'resources', 'sidebarRight', 'sidebarRightTabs',
 ]
 
 /**
@@ -78,6 +79,7 @@ export function apply(ctx: Context): void {
 
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries')
   const t = ctx.locale.bind(NS)
+  registerSidebarChat(ctx, t)
   const chatStore = createChatStore()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
   const transcriptView = new TranscriptViewPolicy(

+ 7 - 0
packages/client/ui-chat/src/client/index.ts

@@ -1,5 +1,12 @@
 /** Browser Chat target plugin. */
 export { apply, inject } from './apply.ts'
+export {
+  ConversationSlotPanel, FixedChatConversationView, parseSidebarChatAddress,
+  sidebarChatAddress, SIDEBAR_CHAT_ADDRESS, SIDEBAR_CHAT_ID,
+} from './sidebar-chat/index.tsx'
+export type {
+  ConversationSlotPanelProps, SidebarChatResource, SidebarChatTabProps,
+} from './sidebar-chat/index.tsx'
 export type {} from './conversation-nodes/assistant.ts'
 export type {} from './conversation-nodes/command.ts'
 export type {} from './conversation-nodes/compaction.ts'

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

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

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

@@ -0,0 +1,197 @@
+/** 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 '../locale.ts'
+import css from './SidebarChat.module.css'
+
+/** Stable implementation identity for the Sidebar tab body. */
+export const SIDEBAR_CHAT_ID = '@deepseek-ai/dsh-client-ui-chat'
+
+/** Resource-address prefix for an embedded Session chat. */
+export const SIDEBAR_CHAT_ADDRESS = 'dsh-resource://chat/session/'
+
+/** Value retained by one live chat resource occurrence. */
+export interface SidebarChatResource {
+  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 {
+    chat: SidebarChatResource
+  }
+
+  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 sidebarChatAddress(address: SubagentAddress): string {
+  const query = new URLSearchParams({
+    parent: address.parentSessionId,
+    mode: address.mode,
+  })
+  return `${SIDEBAR_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 parseSidebarChatAddress(value: string): SubagentAddress | undefined {
+  let url: URL
+  try {
+    url = new URL(value)
+  } catch (_invalidUrl) {
+    return undefined
+  }
+  if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'chat') 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 chatResourceProvider(sessions: ISessions): ResourceProvider<'chat'> {
+  return {
+    protocol: 'chat',
+    async *open(resourceAddress, { signal }) {
+      const address = parseSidebarChatAddress(resourceAddress)
+      if (address === undefined) throw new Error(`ui-chat: invalid chat resource address "${resourceAddress}"`)
+      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<'chat'>(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(chatResourceProvider(ctx.sessions)),
+    'ui-chat: Sidebar chat resources',
+  )
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id: SIDEBAR_CHAT_ID,
+    kind: 'chat',
+    patterns: [`${SIDEBAR_CHAT_ADDRESS}**`],
+    priority: 'builtin',
+    canOpen: address => parseSidebarChatAddress(address) !== undefined,
+    title: (address) => {
+      const child = parseSidebarChatAddress(address)?.childSessionId
+      return child === undefined
+        ? t('view.chat')
+        : ctx.sessions.list.getSnapshot().byId[child]?.displayTitle ?? child
+    },
+  } satisfies SidebarRightTabDefinition), 'ui-chat: Sidebar chat type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab',
+    key: SIDEBAR_CHAT_ID,
+    children: { 'sidebar.chat.conversation': { kind: 'single', scope: 'session' } },
+  }, SidebarChatTab)), 'ui-chat: Sidebar chat body')
+  ctx.effect(() => ctx.slots.inject('sidebar.chat.conversation', () => ctx.slots.register({
+    name: 'sidebar.chat.conversation',
+  }, ConversationSlotPanel)), 'ui-chat: Sidebar Conversation')
+}

+ 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) => {

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

@@ -0,0 +1,205 @@
+// @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, parseSidebarChatAddress,
+  registerSidebarChat, sidebarChatAddress, SIDEBAR_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 = sidebarChatAddress(ADDRESS)
+    expect(resource).toBe('dsh-resource://chat/session/child%231?parent=parent%2Fa&mode=continuable')
+    expect(parseSidebarChatAddress(resource)).toEqual(ADDRESS)
+    expect(parseSidebarChatAddress(sidebarChatAddress({ ...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://chat/other/child?parent=parent&mode=continuable',
+    'dsh-resource://chat/session/child?mode=continuable',
+    'dsh-resource://chat/session/child?parent=&mode=continuable',
+    'dsh-resource://chat/session/child?parent=parent&mode=unknown',
+    'dsh-resource://chat/session/%?parent=parent&mode=continuable',
+  ])('rejects %s', (address) => {
+    expect(parseSidebarChatAddress(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 list = {
+      getSnapshot: () => ({
+        ids: [],
+        byId: { [CHILD]: { displayTitle: 'Worker' } },
+        phase: 'ready',
+        subagentsByParent: {},
+        jobsBySession: {},
+      } as unknown as SessionListState),
+      subscribe: () => () => {},
+    }
+    let provider: ResourceProvider<'chat'> | undefined
+    let definition: SidebarRightTabDefinition | undefined
+    const registrations: { options: Record<string, unknown>; component: unknown }[] = []
+    const ctx = {
+      sessions: { retain, list } as unknown as ISessions,
+      resources: { register: (value: ResourceProvider<'chat'>) => { 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 === 'view.chat' ? 'Chat' : key)
+
+    expect(definition?.id).toBe(SIDEBAR_CHAT_ID)
+    expect(definition?.canOpen?.(sidebarChatAddress(ADDRESS))).toBe(true)
+    expect(definition?.canOpen?.('dsh-resource://chat/invalid')).toBe(false)
+    expect(definition?.title(sidebarChatAddress(ADDRESS))).toBe('Worker')
+    expect(definition?.title(sidebarChatAddress({ ...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(sidebarChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]()
+    expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } })
+    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 alreadyAborted = new AbortController()
+    alreadyAborted.abort()
+    const stopped = provider!.open(sidebarChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]()
+    expect((await stopped.next()).done).toBe(false)
+    expect(await stopped.next()).toEqual({ done: true, value: undefined })
+    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: sidebarChatAddress(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: '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' })
+  })
+})

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

@@ -59,12 +59,18 @@
     {
       "path": "../../session/session-turn-outline"
     },
+    {
+      "path": "../../subagent/subagent"
+    },
     {
       "path": "../../settings/settings"
     },
     {
       "path": "../locale"
     },
+    {
+      "path": "../resources"
+    },
     {
       "path": "../store"
     },

+ 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-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-phase='active'] .viewArea {
   flex: 1 0 auto;
   min-height: auto;
 }
 
-.root[data-phase='active'] .composerSeat {
+.root[data-phase='active'] .composerSeat,
+.embeddedBody[data-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-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-phase='settling'] .composerSeat {
   visibility: hidden;
 }

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

@@ -0,0 +1,175 @@
+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 column = container.offsetWidth
+    container.style.setProperty('--dsh-conversation-column-width', `${column}px`)
+    const preference = readWidthPreference()
+    if (preference === null) container.style.removeProperty('--dsh-chat-user-width')
+    else container.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
+    container.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>
   )
 }

+ 19 - 15
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,22 +671,22 @@ 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
     // 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) })
-    expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px')
+    Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true })
+    act(() => { fireResize(content) })
+    expect(content.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px')
     // No dragged preference: the user-width override stays absent so the
     // adaptive clamp term applies.
-    expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('')
+    expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('')
   })
 
   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
+    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
@@ -701,13 +705,13 @@ describe('ConversationRoot resident composer', () => {
       // inside both bounds (max = 1600 − 176 = 1424 keeps the handles on-column).
       fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 })
       fireEvent.pointerUp(handle, { pointerId: 1, clientX: 825, clientY: 300 })
-      expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px')
+      expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px')
       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) })
-      expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
+      Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true })
+      act(() => { fireResize(content) })
+      expect(content.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
       // press/release rounds) must not commit the clamped display value over
@@ -715,7 +719,7 @@ describe('ConversationRoot resident composer', () => {
       fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 })
       fireEvent.pointerUp(handle, { pointerId: 1, clientX: 800, clientY: 300 })
       expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
-      expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
+      expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
       // No reset affordance on the handle: double-click leaves the preference alone.
       fireEvent.doubleClick(handle)
       expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')

+ 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` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。
 
 ### 序列化
 

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

@@ -57,6 +57,7 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",

+ 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: '@[Investigate startup](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: '@[Investigate startup](dsh-session:IndvcmtlciI)',
+        label: 'researcher',
+        appearance: 'session',
+        clipboardText: '@[Investigate startup](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({

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

@@ -23,6 +23,9 @@
     {
       "path": "../../context/session-reference"
     },
+    {
+      "path": "../../session/session-title"
+    },
     {
       "path": "../../typert/protocol"
     },

+ 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 = {

+ 33 - 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()

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

@@ -30,8 +30,10 @@
       "inject": [
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-locale",
+        "@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"
       ],
@@ -49,6 +51,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "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 +67,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:^"
   },

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

@@ -250,6 +250,28 @@
   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%;
+  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

@@ -10,6 +10,7 @@ import {
 import type {} from '@deepseek-ai/dsh-client-locale/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 +28,13 @@ 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 conversation slots, session navigation, and Sidebar resources. */
+export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']
+
+function sidebarChatAddress(address: SubagentAddress): string {
+  const query = new URLSearchParams({ parent: address.parentSessionId, mode: address.mode })
+  return `dsh-resource://chat/session/${encodeURIComponent(address.childSessionId)}?${query}`
+}
 
 /** Claim the composer for one-shot history or an unavailable continuation owner. */
 function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -56,6 +62,12 @@ export function apply(ctx: ClientContext): void {
     openChild(address: SubagentAddress) {
       ctx.uiWorkspace.openSession(address)
     },
+    openChildAside(address: SubagentAddress) {
+      ctx.sidebarRight.openResource(sidebarChatAddress(address), {
+        kind: 'chat',
+        preferNewPane: true,
+      })
+    },
     refresh(parentSessionId: SessionId) {
       void sessions.refreshSubagents(parentSessionId)
     },

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

@@ -38,6 +38,7 @@ export const zh = {
   'count.running.other': '{count} 个子代理,正在运行',
   'switcher.aria': '切换子代理:{title}',
   'tree.aria': '子代理会话',
+  'open.sidebar': '在侧边栏打开 {label}',
   'readonly.oneShot.title': '一次性子代理记录',
   'readonly.title': '此子代理暂时只读',
   'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
@@ -79,6 +80,7 @@ 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',
   '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.',

+ 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://chat/session/c1?parent=parent&mode=continuable',
+          { kind: 'chat', 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',

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

@@ -32,6 +32,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: 0aa444c6ab8318586e323fcbcf21165e5d5bc11d
+README.zh.md: 5da631b846e979916c49b302f5debca8af528325

+ 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 the mention 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 mention label. 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 attaches each candidate's canonical mention.
 
 ### 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、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建标签,再回退到 mention 标签。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
 
 ### 配置
 

+ 5 - 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:^"
   },
@@ -68,6 +69,9 @@
     "@deepseek-ai/dsh-spill": {
       "optional": true
     },
+    "@deepseek-ai/dsh-subagent": {
+      "optional": true
+    },
     "@deepseek-ai/dsh-session-projection-cache": {
       "optional": true
     }
@@ -88,6 +92,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:^",

+ 25 - 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 { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client'
 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,23 @@ 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']))
-    }
-    if (record.header.isSeeded) return undefined
-    return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(
-      record.header,
-      SessionLogOffset(0),
-      ['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 as SubagentIdentityProjection | null | undefined
+    return { label, displayTitle: subagent?.label ?? label }
   }
 
   /**

+ 3 - 1
packages/context/session-reference/src/types.ts

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

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

@@ -7,6 +7,7 @@ import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-s
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
 import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import type { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import SessionReferenceResolver, {
   decodeSessionReferenceUri,
@@ -54,11 +55,22 @@ 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?: SubagentIdentityProjection | 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 +645,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 +687,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 +706,39 @@ 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)
+  })
+
   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 +757,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 +775,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,6 +791,7 @@ describe('session reference discovery and preparation', () => {
     expect(candidates).toEqual([{
       sessionId: SessionId('source]'),
       label: 'source]',
+      displayTitle: 'source]',
       cwd: '/same',
       sameWorkspace: true,
       createdAt: 20,
@@ -842,7 +882,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-chat), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-chat 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-chat/src/client/sidebar-chat/index.tsx:42',
+  },
   {
     key: 'sidebar.footer.action',
     kind: 'list',
@@ -2556,6 +2593,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: 'SidebarRightTabInjected',
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-chat SidebarChatTab',
       'client-ui-deliverables ReviewTab',
       'client-ui-plan PlanPreview',
       'client-ui-sidebar-browser BrowserBody',

+ 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',

+ 18 - 0
pnpm-lock.yaml

@@ -2742,6 +2742,9 @@ 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-store':
         specifier: workspace:^
         version: link:../store
@@ -2808,6 +2811,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
@@ -3887,6 +3893,9 @@ importers:
       '@deepseek-ai/dsh-session-reference':
         specifier: workspace:^
         version: link:../../context/session-reference
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session/session-title
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
@@ -4765,6 +4774,9 @@ importers:
       '@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 +4792,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 +5618,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

+ 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