Explorar o código

fix(web): activate selected view for blank sessions

fix(web): activate selected views before blank gating
imccyu hai 3 semanas
pai
achega
d7abd0a01e
Modificáronse 24 ficheiros con 265 adicións e 51 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
  2. 3 1
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
  3. 3 1
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
  4. 2 2
      docs/subsystems/conversation.i18n.yaml
  5. 3 3
      docs/subsystems/conversation.md
  6. 3 3
      docs/subsystems/conversation.zh.md
  7. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/inbox.ts
  8. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  9. 3 1
      packages/client/ui-conversation/README.md
  10. 3 1
      packages/client/ui-conversation/README.zh.md
  11. 41 8
      packages/client/ui-conversation/src/client/apply.ts
  12. 4 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  13. 3 1
      packages/client/ui-conversation/src/client/conversation/assembler.ts
  14. 12 2
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  15. 6 14
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  16. 22 1
      packages/client/ui-conversation/src/client/stores.ts
  17. 17 0
      packages/client/ui-conversation/src/client/view-selection.ts
  18. 81 4
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  19. 12 0
      packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts
  20. 4 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  21. 12 1
      packages/client/ui-conversation/tests/conversation-store.client.spec.ts
  22. 3 2
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  23. 20 0
      packages/client/ui-conversation/tests/view-selection.client.spec.ts
  24. 3 0
      packages/client/ui-trajectory/tests/views.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: bcd1233b325b79b10c3ddbdfc364a1bfbebd3606
-2026-08-09-client-conversation-node-assembly.zh.md: 2050ae7daf37aafa199a95eb90629a4e6f4021ae
+2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
+2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -309,7 +309,9 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface
 
 [`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) stores an independent builder factory for each target and shares no Session's ordering or caches.
 
-A target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts.
+A shell selection or a target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts.
+
+The shell synchronously resolves the persisted selection when a Session binding becomes available, when a cached binding becomes current, or when the View roster changes, then explicitly activates that registered View or the Chat fallback. Tab and focus actions activate their resolved target before updating selection state. A blank Session does not render the View slot, and `ConversationSnapshot.activeTargets` derives only from materialized active snapshots without querying inactive target Contexts for activity.
 
 Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -309,7 +309,9 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何
 
 [`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 保存独立的 builder factory,不共享某个 Session 的排序或缓存。
 
-target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。
+shell 选择或 target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。
+
+Session binding 可用、缓存的 binding 成为 current 或 View roster 变化时,shell 会同步解析持久化选择,再显式激活已注册的偏好 View 或 Chat fallback。Tab 与 focus action 在更新选择状态前先激活解析出的 target。blank Session 不渲染 View slot;`ConversationSnapshot.activeTargets` 只从已物化的 active snapshot 派生,不查询 inactive target Context 的 activity。
 
 普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
 

+ 2 - 2
docs/subsystems/conversation.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/conversation.md
-conversation.md: 1a82b9981fd69f1cef23b90ba0b5d2bc751610d3
-conversation.zh.md: 80a8d371eefb518e8caedca230c90b5040a32a13
+conversation.md: df1476537b95690ae2055f367e8586653b99a9a9
+conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62

+ 3 - 3
docs/subsystems/conversation.md

@@ -22,9 +22,9 @@ Chat and Trajectory may recognize the same durable event family, but each keeps
 
 ## Target activation
 
-Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it; its first subscriber does. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one.
+Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it. The shell explicitly activates its persisted or newly selected View, while another consumer activates a target through its first source subscription. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one.
 
-The mounted View controls demand through its target source. Persisted selection, a tab switch, and a third-party View use the same subscription path, so the assembler does not select Chat or another default target.
+The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations.
 
 ## Replayable event families
 
@@ -253,6 +253,6 @@ Add focused tests that establish these outcomes:
 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes.
 7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
-8. Creating a target source performs no builder work; its first subscription performs one complete replacement, later updates reach every active target, and resubscription performs no replacement.
+8. Creating a target source performs no builder work; explicit selection or the first subscription performs one complete replacement, later updates reach every active target, and repeated activation performs no replacement.
 
 Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node.

+ 3 - 3
docs/subsystems/conversation.zh.md

@@ -22,9 +22,9 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
 
 ## Target 激活
 
-每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它,首个 subscriber 才会激活。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。
+每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它。shell 会显式激活持久化选择或新选择的 View,其他消费者则通过 target source 的首个订阅激活 target。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。
 
-已挂载的 View 通过自己的 target source 控制需求。持久化选择、标签页切换和第三方 View 使用同一订阅路径,因此 assembler 不会指定 Chat 或其他默认 target
+shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作
 
 ## 可回放 event family
 
@@ -253,6 +253,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
 7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。
-8. 创建 target source 不执行 builder 工作;首次订阅执行一次完整 replace,后续更新送达所有 active target,重新订阅不会再次 replace。
+8. 创建 target source 不执行 builder 工作;显式选择或首次订阅执行一次完整 replace,后续更新送达所有 active target,重复激活不会再次 replace。
 
 流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/inbox.ts

@@ -106,7 +106,7 @@ function applySplice(
 
 const NEXT_STEP_INBOX_KIND = 'inbox-next-step'
 
-/** Cumulative next-step Inbox state used to classify steering. */
+/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */
 export const nextStepInboxDefinition: ConversationNodeDefinition<InboxState> = {
   kind: NEXT_STEP_INBOX_KIND,
   match: (event) => {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: b88c8015a26ae7b83a60fd058804c0e838643a6f
-README.zh.md: 4f65b861965e241f7553942407c5a23536703b33
+README.md: ce99ede306e0b0aeac24d7b21beacd0a538380b6
+README.zh.md: 5a2e481071ed3303983d5b57c68690ea9f4c4ab3

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

@@ -29,7 +29,7 @@ English | [中文](README.zh.md)
 
 The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
 
-A target source creates its Session-owned builder on first subscription. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it, unsubscription does not deactivate it, and no built-in target is forced active.
+A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
 
 Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
 
@@ -40,6 +40,8 @@ The package registers the optional-Session `conversation` shell, strict Session
 
 View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
 
+The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
+
 The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id.

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
 
-target source 在首次订阅时创建该 Session 自有的 builder。assembler 从当前 Context 对该 target 执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅不会停用 target,也不会强制激活任何内建 target。
+shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅不会停用 target。
 
 target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
 
@@ -40,6 +40,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
 
+Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
+
 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。

+ 41 - 8
packages/client/ui-conversation/src/client/apply.ts

@@ -16,7 +16,7 @@ import type {
   ConversationSessionInjected,
 } from './contract/slots.ts'
 import type { InputNotice } from './contract/input.ts'
-import { createConversationStore } from './stores.ts'
+import { createConversationStore, readConversationViewPreference } from './stores.ts'
 import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
 import type { IConversation } from './service.ts'
 import { ComposerBlockRegistry } from './input/blocks.ts'
@@ -30,6 +30,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
 import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
 import { InputBar } from './skeleton/InputBar.tsx'
 import { todoDockEntry } from './skeleton/TodoPanel.tsx'
+import { resolveActiveView } from './view-selection.ts'
 import { en, NS, zh, type ConversationKey } from './locales.ts'
 import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
 
@@ -129,25 +130,47 @@ export function apply(ctx: Context): void {
     }
     return tabs
   }
+  const activateView = (sessionId: SessionId, preferred: string | null): void => {
+    const active = resolveActiveView(viewTabs(), preferred)
+    if (active !== undefined) uiConversation.binding(sessionId).activate(active.id)
+  }
+  const restoreView = (sessionId: SessionId): void => {
+    activateView(sessionId, readConversationViewPreference(sessionId))
+  }
+  const restoreCurrentView = (): void => {
+    const sessionId = sessions.list.getSnapshot().current
+    if (sessionId !== undefined && sessions.binding(sessionId) !== undefined) {
+      restoreView(sessionId)
+    }
+  }
   const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
   const refreshViews = (): void => {
     const current = conversationViews.getSnapshot()
     const next = viewTabs()
-    if (current.length === next.length
+    const unchanged = current.length === next.length
       && current.every((tab, index) => {
         const candidate = next.at(index)
         return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
-      })) return
-    conversationViews.set(next)
+      })
+    if (!unchanged) conversationViews.set(next)
+    restoreCurrentView()
   }
   ctx.effect(() => {
+    let currentSessionId = sessions.list.getSnapshot().current
     const disposeViews = slots.subscribe('conversation.view', refreshViews)
     const disposeLocale = ctx.locale.subscribe(refreshViews)
+    const disposeCurrent = sessions.list.subscribe(() => {
+      const nextSessionId = sessions.list.getSnapshot().current
+      if (nextSessionId === currentSessionId) return
+      currentSessionId = nextSessionId
+      restoreCurrentView()
+    })
     return () => {
+      disposeCurrent()
       disposeLocale()
       disposeViews()
     }
-  }, 'ui-conversation: View roster')
+  }, 'ui-conversation: View selection')
 
   const inputHub = new InputHub(ctx, t)
   const composerBlocks = new ComposerBlockRegistry()
@@ -159,9 +182,11 @@ export function apply(ctx: Context): void {
     props: ['inputActions'],
     resolve: (binding) => {
       const shell = inputHub.shellFor(binding)
+      const conversation = uiConversation.binding(binding)
+      restoreView(binding.sessionId)
       return {
         hooks: {
-          conversation: uiConversation.binding(binding).snapshot,
+          conversation: conversation.snapshot,
           input: shell.state,
         },
         props: { inputActions: shell.actions },
@@ -218,9 +243,13 @@ export function apply(ctx: Context): void {
       'conversation.view': { kind: 'list', scope: 'session' },
     },
     store: conversationStore,
-    inject: (sessionId: SessionId, _actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
+    inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
       hooks: { conversationViews },
       bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
+      openView: (view, focus) => {
+        activateView(sessionId, view)
+        actions.openView(view, focus)
+      },
     }),
   }, ConversationSession)
 
@@ -233,9 +262,13 @@ export function apply(ctx: Context): void {
       'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
     },
     store: conversationStore,
-    inject: (): ConversationSessionHeaderInjected => ({
+    inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionHeaderInjected => ({
       hooks: { conversationViews },
       open: (id) => { sessions.open(id) },
+      selectView: (view) => {
+        activateView(sessionId, view)
+        actions.setView(view)
+      },
     }),
   }, ConversationSessionHeader)
 

+ 4 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -226,6 +226,8 @@ export interface ConversationSessionInjected {
   readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
   /** Bind input draft persistence to the Session-owned store instance. */
   bindDraftMirror: (write: (text: string) => void) => () => void
+  /** Select and activate one View while addressing an opaque focus request to it. */
+  openView: (view: string, focus: string) => void
 }
 
 /** Business callbacks injected into the strict Session header. */
@@ -234,6 +236,8 @@ export interface ConversationSessionHeaderInjected {
   readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
   /** Select a Session through the Session Controller. */
   open: (sessionId: SessionId) => void
+  /** Select and activate one registered Conversation View. */
+  selectView: (view: string) => void
 }
 
 /** Owner share of the resident composer bar. */

+ 3 - 1
packages/client/ui-conversation/src/client/conversation/assembler.ts

@@ -291,6 +291,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
     if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
     if (this.replacePending) {
       this.replaceLocationData()
+      let published = false
       for (const target of this.activeTargets) {
         const view = this.views.get(target)
         if (view === undefined) continue
@@ -300,12 +301,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
           nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
           timeline: this.locationIndex.snapshot(),
         })
+        published = true
       }
       this.replacePending = false
       this.dirty.clear()
       this.dirtyByTarget.clear()
       this.timelineDirty = false
-      return this.activeTargets.size > 0
+      return published
     }
 
     let published = false

+ 12 - 2
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -23,9 +23,15 @@ import { ConversationViewRegistry } from './view-registry.ts'
 /** Observable faces published for one Session's Conversation assembly. */
 export interface ConversationBinding {
   readonly snapshot: ObservableSnapshot<ConversationSnapshot>
+  /**
+   * Add one selected target to the Session's monotonic active set.
+   * @param target - registered or subsequently registered Conversation target.
+   */
+  activate(target: string): void
   /**
    * Resolve one target-owned snapshot source.
-   * The first subscriber activates the target for the remaining Session lifetime.
+   * The first subscriber activates the target unless shell selection already
+   * activated it; activation lasts for the remaining Session lifetime.
    * @param target - registered Conversation target.
    * @returns identity-stable source following the target.
    */
@@ -64,7 +70,7 @@ class BoundConversation implements ConversationBinding {
         getSnapshot: () => views.get(target),
         subscribe: (listener) => {
           const unsubscribe = this.snapshot.subscribe(listener)
-          if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot())
+          this.activate(target)
           return unsubscribe
         },
       }
@@ -73,6 +79,10 @@ class BoundConversation implements ConversationBinding {
     return source as ObservableSnapshot<ConversationViewSnapshotMap[Target] | undefined>
   }
 
+  activate(target: string): void {
+    if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot())
+  }
+
   rebuild(): void { this.publish(this.assembler.rebuildRegistry()) }
 
   dispose(): void {

+ 6 - 14
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -8,7 +8,7 @@ import type {
   ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
 } from '../contract/slots.ts'
 import { conversationPhase } from '../contract/snapshot.ts'
-import type { ViewTab } from '../contract/views.ts'
+import { resolveActiveView } from '../view-selection.ts'
 import css from './ConversationRoot.module.css'
 
 /** Full props composed from the strict session body contract. */
@@ -23,14 +23,6 @@ interface Breadcrumb {
   readonly subagent: boolean
 }
 
-const DEFAULT_VIEW_ID = 'chat'
-
-/** Resolve a persisted selection, then registered Chat, without choosing another View. */
-function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined {
-  const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId)
-  return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
-}
-
 function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
   const chain: Breadcrumb[] = []
   const seen = new Set<SessionId>()
@@ -65,8 +57,8 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
  * @returns the hidden blank-session header or visible title and tabs.
  */
 export function ConversationSessionHeader({
-  sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, actions,
-  renderSlot, open, t,
+  sessionId, useSession, useSessions, useConversation, useConversationViews, useStore,
+  renderSlot, open, selectView, t,
 }: ConversationSessionHeaderProps) {
   const tabs = useConversationViews(value => value)
   const selectedId = useStore(s => s.view)
@@ -151,7 +143,7 @@ export function ConversationSessionHeader({
                   role="tab"
                   aria-selected={viewTab.id === active?.id}
                   className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
-                  onClick={() => { actions.setView(viewTab.id) }}
+                  onClick={() => { selectView(viewTab.id) }}
                 >
                   {viewTab.label}
                 </button>
@@ -172,7 +164,7 @@ export function ConversationSessionHeader({
  */
 export function ConversationSession({
   useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
-  renderSlot, bindDraftMirror,
+  renderSlot, bindDraftMirror, openView,
 }: ConversationSessionProps) {
   const tabs = useConversationViews(value => value)
   const selectedId = useStore(s => s.view)
@@ -196,7 +188,7 @@ export function ConversationSession({
     <div className={css.viewArea}>
       {active !== undefined && renderSlot('conversation.view', {
         viewRequest,
-        openView: actions.openView,
+        openView,
         completeViewRequest: actions.completeViewRequest,
       }, { only: active.id })}
     </div>

+ 22 - 1
packages/client/ui-conversation/src/client/stores.ts

@@ -1,7 +1,10 @@
 /** Per-session Conversation store shared by the shell body and header. */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ConversationStoreState } from './contract/views.ts'
 
+const CONVERSATION_STORE_KEY = 'dsh.conversation'
+
 /** Declared write set for the Conversation shell. */
 type ConversationActions = {
   setDraft: (draft: ConversationStoreState, text: string) => void
@@ -17,7 +20,7 @@ type ConversationActions = {
 export function createConversationStore(): EngineStoreHandle<ConversationStoreState, ConversationActions> {
   return defineStore({
     init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }),
-    persist: 'dsh.conversation',
+    persist: CONVERSATION_STORE_KEY,
     actions: {
       setDraft: (d, text: string) => { d.draft = text },
       setView: (d, view: string) => { d.view = view },
@@ -29,3 +32,21 @@ export function createConversationStore(): EngineStoreHandle<ConversationStoreSt
     },
   })
 }
+
+/**
+ * Read the persisted View preference before the Slot store is materialized.
+ * @param sessionId - Session-scoped persistence suffix.
+ * @returns the preferred View id, or null when storage has no usable value.
+ */
+export function readConversationViewPreference(sessionId: SessionId): string | null {
+  if (typeof localStorage === 'undefined') return null
+  try {
+    const raw = localStorage.getItem(`${CONVERSATION_STORE_KEY}.${sessionId}`)
+    if (raw === null) return null
+    const stored: unknown = JSON.parse(raw)
+    if (typeof stored !== 'object' || stored === null || !('view' in stored)) return null
+    return typeof stored.view === 'string' ? stored.view : null
+  } catch {
+    return null
+  }
+}

+ 17 - 0
packages/client/ui-conversation/src/client/view-selection.ts

@@ -0,0 +1,17 @@
+import type { ViewTab } from './contract/views.ts'
+
+const DEFAULT_VIEW_ID = 'chat'
+
+/**
+ * Resolve a preferred registered View, then Chat, without choosing another View.
+ * @param tabs - currently registered Views.
+ * @param selectedId - preferred View identity, when one is stored.
+ * @returns the selected View, Chat fallback, or undefined when neither is registered.
+ */
+export function resolveActiveView(
+  tabs: readonly ViewTab[],
+  selectedId: string | null,
+): ViewTab | undefined {
+  const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId)
+  return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
+}

+ 81 - 4
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -9,7 +9,7 @@ import {
 import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
 import {
   apply, inject, type ComposerBarInjected, type ConversationInjected,
-  type ConversationSessionInjected, type ViewTab,
+  type ConversationSessionHeaderInjected, type ConversationSessionInjected, type ViewTab,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -51,7 +51,7 @@ async function bench() {
 
   const feature = await runtime.mount({ inject: [...inject], apply })
   runtime.renderRoot()
-  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
+  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
     runtime.slots.entries(key)[0]!
   const conversationApi = (id: SessionId) => {
     const entry = entryOf('conversation.session')
@@ -66,6 +66,15 @@ async function bench() {
     const entry = entryOf('conversation')
     return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
   }
+  const headerApi = (id: SessionId) => {
+    const entry = entryOf('conversation.session.header')
+    const instance = runtime.storeOf('conversation.session.header', id) as ConversationInstance
+    const injected = (entry.inject as unknown as (
+      sessionId: SessionId,
+      actions: ConversationActions,
+    ) => ConversationSessionHeaderInjected)(id, instance.actions)
+    return { instance, injected }
+  }
   const composerApi = (id: SessionId | undefined) => {
     const entry = entryOf('conversation.composer.bar')
     return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
@@ -77,7 +86,7 @@ async function bench() {
   const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
     conversationApi(id).injected.hooks.conversationViews
   return {
-    runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
+    runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
     inputApi, viewSource, sessionFake, connectWorkspace,
   }
 }
@@ -87,11 +96,79 @@ describe('Conversation inject API', () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)
     expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
-    expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
+    expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror', 'openView'])
     expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
     await b.runtime.dispose()
   })
 
+  it('activates a target before committing an explicit View selection', async () => {
+    const b = await bench()
+    const binding = b.runtime.ctx.uiConversation.binding(ROOT)
+    const activate = vi.spyOn(binding, 'activate')
+    const removeChat = b.slots.register(
+      { name: 'conversation.view', id: 'chat', order: 0 },
+      (() => null) as never,
+    )
+    const removeTrajectory = b.slots.register(
+      { name: 'conversation.view', id: 'trajectory', order: 10 },
+      (() => null) as never,
+    )
+    await Promise.resolve()
+    activate.mockClear()
+
+    const body = b.conversationApi(ROOT)
+    body.injected.openView('trajectory', 'call-1')
+    expect(activate).toHaveBeenLastCalledWith('trajectory')
+    expect(body.instance.store.getSnapshot()).toMatchObject({
+      view: 'trajectory',
+      viewRequest: { view: 'trajectory', focus: 'call-1' },
+    })
+
+    const header = b.headerApi(ROOT)
+    header.injected.selectView('chat')
+    expect(activate).toHaveBeenLastCalledWith('chat')
+    expect(header.instance.store.getSnapshot().view).toBe('chat')
+
+    removeTrajectory()
+    removeChat()
+    await b.runtime.dispose()
+  })
+
+  it('restores the selected View when a cached Session becomes current', async () => {
+    const b = await bench()
+    const binding = b.runtime.ctx.uiConversation.binding(ROOT)
+    const activate = vi.spyOn(binding, 'activate')
+    const removeChat = b.slots.register(
+      { name: 'conversation.view', id: 'chat', order: 0 },
+      (() => null) as never,
+    )
+    let removeCustom: (() => void) | undefined
+    try {
+      await b.runtime.flush()
+      localStorage.setItem(`dsh.conversation.${ROOT}`, JSON.stringify({
+        draft: '', view: 'custom', viewRequest: null,
+      }))
+
+      b.runtime.ctx.uiSession.adapter.resolve(ROOT)
+      expect(activate).toHaveBeenLastCalledWith('chat')
+      activate.mockClear()
+
+      removeCustom = b.slots.register(
+        { name: 'conversation.view', id: 'custom', order: 10 },
+        (() => null) as never,
+      )
+      await b.runtime.flush()
+      expect(activate).not.toHaveBeenCalled()
+
+      await b.runtime.sessions.setCurrent(ROOT)
+      expect(activate).toHaveBeenLastCalledWith('custom')
+    } finally {
+      removeCustom?.()
+      removeChat()
+      await b.runtime.dispose()
+    }
+  })
+
   it('submits through the provided input machine and mirrors accepted draft edits', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)

+ 12 - 0
packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts

@@ -161,6 +161,18 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
 }
 
 describe('ConversationNodeAssembler', () => {
+  it('reports a replacement only when an active target has a registered builder', () => {
+    const assembler = new RuntimeConversationNodeAssembler(
+      new TestEventDefinitions([]),
+      new TestViewDefinitions([]),
+    )
+
+    expect(assembler.activateTarget('registered-later')).toBe(false)
+    assembler.replaceWindow([], false)
+
+    expect(assembler.flush()).toBe(false)
+  })
+
   it('updates only active targets and never deactivates one after first use', () => {
     type State = { readonly updates: number }
     const definition = (

+ 4 - 1
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -246,7 +246,7 @@ describe('Conversation registries', () => {
     rebuild.mockRestore()
   })
 
-  it('activates each target on first use and keeps it active after unsubscribe', async () => {
+  it('activates each target on explicit selection or first use and never deactivates it', async () => {
     const { uiConversation, binding, views } = await bootRegistries()
     const chat = viewDefinition('chat')
     const trajectory = viewDefinition('trajectory')
@@ -262,6 +262,9 @@ describe('Conversation registries', () => {
     expect(createChat).not.toHaveBeenCalled()
     expect(createTrajectory).not.toHaveBeenCalled()
 
+    conversation.activate('chat')
+    expect(createChat).toHaveBeenCalledOnce()
+
     const unsubscribeChat = chatSource.subscribe(vi.fn())
     const trajectoryListener = vi.fn()
     const unsubscribeTrajectory = trajectorySource.subscribe(trajectoryListener)

+ 12 - 1
packages/client/ui-conversation/tests/conversation-store.client.spec.ts

@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 import { beforeEach, describe, expect, it } from 'vitest'
-import { createConversationStore } from '../src/client/stores.ts'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createConversationStore, readConversationViewPreference } from '../src/client/stores.ts'
 
 const KEY = 'dsh.conversation'
 
@@ -54,4 +55,14 @@ describe('createConversationStore', () => {
     first.actions.setDraft('only first')
     expect(second.store.getSnapshot().draft).toBe('')
   })
+
+  it('reads only a usable persisted View preference', () => {
+    const sessionId = 'sess-1' as SessionId
+    const store = createConversationStore().create(sessionId)
+    store.actions.setView('trajectory')
+    expect(readConversationViewPreference(sessionId)).toBe('trajectory')
+
+    localStorage.setItem(`${KEY}.${sessionId}`, '{invalid')
+    expect(readConversationViewPreference(sessionId)).toBeNull()
+  })
 })

+ 3 - 2
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -203,6 +203,7 @@ function mount(
           actions={store.actions}
           renderSlot={renderSlot as never}
           open={open}
+          selectView={(view) => { store.actions.setView(view) }}
           t={t}
         />
       )
@@ -227,6 +228,7 @@ function mount(
           actions={store.actions}
           renderSlot={renderSlot as never}
           bindDraftMirror={write => wiring.bindMirror(write)}
+          openView={(view, focus) => { store.actions.openView(view, focus) }}
         />
       )
     }
@@ -433,8 +435,7 @@ describe('ConversationRoot resident composer', () => {
         { ...workspace('second'), title: 'Selected Folder' },
       ],
     )
-    // Hero chrome present, view ring absent; scroll host already wraps the
-    // resident composer so the blank → active flip does not remount it.
+    // Hero chrome is present and the selected View slot remains absent.
     const host = b.view.container.querySelector('[data-conversation-scroll]')
     const header = b.view.container.querySelector('header')
     expect(host).not.toBeNull()

+ 20 - 0
packages/client/ui-conversation/tests/view-selection.client.spec.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { resolveActiveView } from '../src/client/view-selection.ts'
+import type { ViewTab } from '../src/client/contract/views.ts'
+
+describe('resolveActiveView', () => {
+  it('resolves the preferred registered View and Chat fallback', () => {
+    const tabs: readonly ViewTab[] = [
+      { id: 'chat', label: 'Chat' },
+      { id: 'custom', label: 'Custom' },
+    ]
+
+    expect(resolveActiveView(tabs, 'custom')?.id).toBe('custom')
+    expect(resolveActiveView(tabs, 'removed')?.id).toBe('chat')
+    expect(resolveActiveView(tabs, null)?.id).toBe('chat')
+  })
+
+  it('does not choose an arbitrary registered View', () => {
+    expect(resolveActiveView([{ id: 'custom', label: 'Custom' }], null)).toBeUndefined()
+  })
+})

+ 3 - 0
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -264,6 +264,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
   }
   const binding: ConversationBinding = {
     snapshot: conversationStore,
+    activate: () => {},
     target: target => targetSources[target],
   }
   vi.spyOn(uiConversation, 'binding').mockReturnValue(binding)
@@ -389,6 +390,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
         actions={conversation.actions}
         renderSlot={() => null}
         open={vi.fn()}
+        selectView={conversation.actions.setView}
         t={tConversation}
       />
       <ConversationSession
@@ -398,6 +400,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
         actions={conversation.actions}
         renderSlot={renderSlot}
         bindDraftMirror={() => () => {}}
+        openView={conversation.actions.openView}
       />
     </>,
   )