Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/master' into HEAD

lsdsjy 3 týždňov pred
rodič
commit
fa5de9ea27
78 zmenil súbory, kde vykonal 2089 pridanie a 1067 odobranie
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  2. 0 1
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  3. 0 1
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
  5. 29 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
  6. 29 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
  8. 8 0
      .agents/notes/implemented/feature/2026-08-05-agent-teams.md
  9. 8 0
      .agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
  10. 2 2
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
  11. 1 1
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md
  12. 1 1
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md
  13. 6 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml
  14. 39 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
  15. 39 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md
  16. 19 0
      .github/workflows/ci.yml
  17. 13 2
      apps/cli/tests/agent-team-headless.e2e.ts
  18. 2 11
      apps/cli/tests/profiles/headless/team-snapshot.patch.yml
  19. 19 5
      apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs
  20. 33 2
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  21. 2 8
      apps/web/tests/agent-team-panel.overlay.yml
  22. 1 1
      apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md
  23. 56 7
      apps/web/tests/message-actions.e2e.ts
  24. 167 0
      apps/web/tests/workspace-recency.e2e.ts
  25. 1 0
      apps/web/tsconfig.json
  26. 2 2
      packages/api/session-controller/README.i18n.yaml
  27. 2 0
      packages/api/session-controller/README.md
  28. 2 0
      packages/api/session-controller/README.zh.md
  29. 1 4
      packages/api/session-controller/src/commands.ts
  30. 65 3
      packages/api/session-controller/tests/session-fork.host.spec.ts
  31. 2 2
      packages/client/connection/README.i18n.yaml
  32. 2 0
      packages/client/connection/README.md
  33. 2 0
      packages/client/connection/README.zh.md
  34. 1 2
      packages/client/connection/src/client/fixture.ts
  35. 29 0
      packages/client/connection/tests/fixture.client.spec.ts
  36. 285 0
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  37. 146 0
      packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
  38. 4 386
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  39. 3 32
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  40. 44 0
      packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx
  41. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  42. 3 3
      packages/client/ui-workspace/README.md
  43. 3 3
      packages/client/ui-workspace/README.zh.md
  44. 0 6
      packages/client/ui-workspace/src/client/contract/slots.ts
  45. 0 3
      packages/client/ui-workspace/src/client/index.ts
  46. 6 5
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  47. 148 236
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  48. 34 19
      packages/client/ui-workspace/src/client/stores.ts
  49. 112 52
      packages/client/ui-workspace/src/client/tree.ts
  50. 2 5
      packages/client/ui-workspace/tests/apply.client.spec.ts
  51. 83 31
      packages/client/ui-workspace/tests/tree.client.spec.ts
  52. 293 152
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  53. 2 2
      packages/experimental/agent-team-profile/README.i18n.yaml
  54. 6 5
      packages/experimental/agent-team-profile/README.md
  55. 6 5
      packages/experimental/agent-team-profile/README.zh.md
  56. 3 10
      packages/experimental/agent-team-profile/cordis.patch.yml
  57. 2 2
      packages/experimental/agent-team-profile/tests/profile.spec.ts
  58. 2 2
      packages/experimental/agent-team/README.i18n.yaml
  59. 1 1
      packages/experimental/agent-team/README.md
  60. 1 1
      packages/experimental/agent-team/README.zh.md
  61. 1 1
      packages/experimental/agent-team/src/index.ts
  62. 2 2
      packages/experimental/tool-agent-team/README.i18n.yaml
  63. 7 6
      packages/experimental/tool-agent-team/README.md
  64. 7 6
      packages/experimental/tool-agent-team/README.zh.md
  65. 3 1
      packages/experimental/tool-agent-team/package.json
  66. 5 5
      packages/experimental/tool-agent-team/src/index.ts
  67. 202 13
      packages/experimental/tool-agent-team/tests/tool-team.spec.ts
  68. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  69. 1 1
      packages/subprocess/subprocess-local/README.md
  70. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  71. 5 4
      packages/subprocess/subprocess-local/src/linux-scope.ts
  72. 20 1
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  73. 6 0
      pnpm-lock.yaml
  74. 18 2
      snapshots/web/message-actions/ui.expected.md
  75. 8 0
      snapshots/web/workspace-recency/manual-blank.expected.md
  76. 7 0
      snapshots/web/workspace-recency/sidebar.expected.md
  77. 9 0
      snapshots/web/workspace-recency/snapshot.yml
  78. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 7523f9d66e7a302f6ce9c77d93671a5b1303a5aa
-2026-08-28-subprocess-native-containment.zh.md: 252a8e9fd058cf37a1a7a70f09394a6811d58580
+2026-08-28-subprocess-native-containment.md: cf3507886f575893e19f72cde6e3f7bf8a1fbdbc
+2026-08-28-subprocess-native-containment.zh.md: 1e825262f786067d03c0cc65b4e59bfef1555741

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md


+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
+2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
+2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md

@@ -0,0 +1,29 @@
+# Agent Note: Session Controller forks stop at the selected turn end
+
+Status: implemented
+
+English | [中文](2026-09-11-session-controller-fork-turn-cut.zh.md)
+
+## Problem
+
+A user input enters the durable inbox before its `turn/start`. Extending a completed-turn fork through the following between-turn events can copy the next input's insertion without its later removal. Continuing the child then executes an input from beyond the selected turn.
+
+## Decision
+
+The [Session Controller](../../../../packages/api/session-controller/README.md) copies the contiguous prefix through the selected `turn/end`, inclusive. Explicit anchors select the first closing event at or after the anchor; omitted and past-end anchors select the last closing event. No event after that closing event belongs to the seed, including queued input, titles, and model settings.
+
+The lower-level `SessionStore.fork()` retains its explicit stable-event semantics from the [log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). Selecting a completed turn in the controller does not request a later stable event.
+
+## Alternatives considered
+
+**Stop only at the next inbox event.** Event-type exceptions still copy unrelated state changes after the selected turn and require the controller to classify plugin-owned events.
+
+**Copy the tail and clear the child's inbox.** Clearing adds child events to cancel input that lies outside the requested prefix, while still inheriting other state from after the selected turn.
+
+## Consequences
+
+A fork inherits the configuration recorded through its selected turn. Later configuration and title events are excluded. The client may independently assign the child's fork title. Events already inside the selected prefix retain their ordinary replay semantics; this decision does not redefine pending input inserted before the selected closing event.
+
+## Verification
+
+Controller tests execute the production loop and check that sending C after forking A excludes the parent's later B from child history and produces one model request. Message, closing-event, omitted, and past-end anchors share this assertion. Model-routing coverage excludes a later configuration change. The Web message-actions snapshot seeds a queued input after the completed turn and verifies the branch action creates a child without that input or its inbox insertion.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Session Controller 分叉截到选中轮次的结束事件
+
+Status: implemented
+
+English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
+
+## Problem
+
+用户输入会先进入持久化 inbox,再出现对应的 `turn/start`。如果已结束轮次的分叉继续复制后面的轮次间事件,就可能复制下一条输入的入队事件,却没有复制其后的出队事件。继续子会话时,就会执行选中轮次之后的输入。
+
+## Decision
+
+[Session Controller](../../../../packages/api/session-controller/README.zh.md) 复制截至选中 `turn/end` 的连续前缀,并包含该事件。显式锚点选择位于锚点或其后的第一条结束事件;省略锚点或锚点超出日志末尾时,选择最后一条结束事件。结束事件之后的所有事件均不属于种子,包括排队输入、标题和模型设置。
+
+底层 `SessionStore.fork()` 保留[纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)规定的显式稳定事件语义。在控制器中选择已结束轮次,不代表请求复制到更晚的稳定事件。
+
+## Alternatives considered
+
+**仅在下一条 inbox 事件处停止。** 按事件类型添加例外仍会复制选中轮次之后的无关状态变化,并要求控制器分类插件所属的事件。
+
+**复制尾部后清空子会话 inbox。** 清空操作会添加子会话事件,以取消本来就在所请求前缀之外的输入,同时仍然继承选中轮次之后的其他状态。
+
+## Consequences
+
+分叉继承截至选中轮次记录的配置。之后的配置和标题事件被排除。客户端可以独立设置子会话的分叉标题。已经位于选中前缀内的事件保留通常的回放语义;本决策不重新定义在选中结束事件之前入队的待处理输入。
+
+## Verification
+
+控制器测试执行生产循环,检查从 A 分叉后发送 C 时,子会话历史不包含父会话后续的 B,并且只产生一次模型请求。消息、结束事件、省略及越界锚点共用此断言。模型路由覆盖排除了后续配置变更。Web 消息操作快照在已结束轮次后植入排队输入,验证分叉操作创建的子会话不包含该输入或其入队事件。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-agent-teams.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-08-05-agent-teams.md
-2026-08-05-agent-teams.md: 1fcbe9c586b78db3d5638074e51745c8c189c80c
-2026-08-05-agent-teams.zh.md: 5520b9c1e7fa039be677881f4d2df504f835ae81
+2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c
+2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464

+ 8 - 0
.agents/notes/implemented/feature/2026-08-05-agent-teams.md

@@ -20,6 +20,14 @@ The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, whi
 
 The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit.
 
+## Profile delegation
+
+The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history without identity correction. Provider-owned child tool visibility remains a [documented limitation](../../../../packages/experimental/tool-agent-team/README.md#known-limitations-and-deferred-work).
+
+## Team identity
+
+The `spawn_teammate` tool prefixes the initial task with a user-role `<system-reminder>` stating `You are teammate "<name>".`. Identity and task enter the same durable inbox message. Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. Identity follows ordinary history through cold recovery and compaction; the plugin does not inspect reminder retention or add replacement messages. Forks inherit the recorded text without a Lead identity correction. Putting identity in the system prompt changes the prefix before inherited history; keeping it in the initial task preserves that prefix without per-step identity bookkeeping. Existing system-embedded identities may require a one-time prompt reconciliation; retained event generations are unchanged.
+
 ## Provisioning and recovery
 
 Creation first appends and flushes a `team/member` provisioning snapshot in the Lead Session, then starts the reserved continuable child through the selected fresh or fork provider. Failure before initial inbox acceptance appends a failed snapshot. Success flushes the child's accepted inbox item before appending active. Recovery recognizes that initial message while it is still pending or after it enters user-message history. Names are reserved by the first provisioning record and never reused, including after failure. Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then stops every live child recorded by the roster; a failed child remains cleanup-owned until its Activation exits, and cleanup rejection fails disposal.

+ 8 - 0
.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md

@@ -20,6 +20,14 @@ Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的
 
 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。
 
+## Profile delegation
+
+[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史,不纠正身份。提供方所拥有的子代理工具可见性仍是[已记录的限制](../../../../packages/experimental/tool-agent-team/README.zh.md#known-limitations-and-deferred-work)。
+
+## Team identity
+
+工具 `spawn_teammate` 在初始任务前加上 user-role `<system-reminder>`,声明 `You are teammate "<name>".`。身份和任务进入同一条持久化收件箱消息。共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。身份随普通历史经历冷恢复和压缩;插件不检查提醒是否保留,也不添加替代消息。fork 继承已记录文本,不补发 Lead 身份修正。把身份放进 system prompt 会在继承历史之前改变前缀;把它留在初始任务中既保留此前缀,也无需每步维护身份提醒。已有的 system 内嵌身份可能需要一次提示词协调;保留的事件格式代际保持不变。
+
 ## Provisioning and recovery
 
 创建操作先在 Lead Session 中追加并 flush `team/member` provisioning 快照,再通过选定 fresh 或 fork provider 启动预留的 continuable child。初始 inbox 获准前的失败会追加 failed 快照;成功会先 flush child 中已接受的 inbox 条目,再追加 active。恢复会在初始消息仍处于 pending 或已进入用户消息历史时识别它。名字由第一条 provisioning 记录永久保留,包括失败后也不能复用。dispose 会关闭准入,中止并等待已获准的创建与 mailbox dispatch 事务,再停止 roster 记录的所有 live child;failed child 在 Activation 退出前仍由 cleanup 拥有,cleanup 拒绝会让 dispose 失败。

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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/simplification/2026-07-28-remove-synthetic-log-only-turns.md
-2026-07-28-remove-synthetic-log-only-turns.md: e5138ad22a68d2c7dd6d201df5522536e1d26414
-2026-07-28-remove-synthetic-log-only-turns.zh.md: 2fb15ff837b4984a069a2a5306f167fe72177f6b
+2026-07-28-remove-synthetic-log-only-turns.md: b35045d036dbb3ea18431b76cec2c41674de5607
+2026-07-28-remove-synthetic-log-only-turns.zh.md: 3dbcd0d9b258f4ea9f4515ba009e75487a96c342

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md

@@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn
 
 The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compaction/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission.
 
-A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution.
+`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) limits its completed-turn operation to the selected closing event.
 
 The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics.
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compaction/* { turn: null }` 标记对,但会显式刷写已闭合的尝试,因为 `/compact` 承诺在放行排队中的提示词前完成持久化。
 
-会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。
+`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)将其已结束轮次操作限制在选中的结束事件处。
 
 历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/simplification/2026-09-10-derived-workspace-recency.md
+2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
+2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c

+ 39 - 0
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md

@@ -0,0 +1,39 @@
+# Agent Note: Derive Workspace recency independently of manual order
+
+Status: implemented
+
+English | [中文](2026-09-10-derived-workspace-recency.zh.md)
+
+## Problem
+
+An editable activity-promoted list can disagree with its displayed timestamps without a drag: an older Session arriving late is promoted ahead of a newer Session already observed. Repeating the same complete list preserves that inversion. The historical [sidebar-order decision](../../archived/feature/2026-08-11-workspace-sidebar-order-and-folding.md) shared one editable order between Manual and Last updated to preserve positions when switching modes. That trade-off does not satisfy chronological browsing.
+
+## Decision
+
+Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
+
+Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
+
+The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
+
+The persistence key stores grouping, expansion, and saved Manual positions. Persisted observed-timestamp data is removed when active account keys are retained. Current metadata owns timestamp accuracy: a cold summary without prompt metadata can fall back to creation time, independently of view ordering.
+
+## Alternatives considered
+
+**Keep activity promotion and sort again on reconnect.** Late summaries and metadata corrections also arrive within a connection. A reconnect-only repair still allows chronology to depend on observation order.
+
+**Restore a separate manual layout on mode switches.** Replacing the visible list with old positions makes entering Manual surprising. Resetting the layout on a mode change gives Manual the simpler meaning of pausing the current ordering.
+
+**Persist a second chronological order.** Current timestamps already determine that order. A second ledger retains synchronization and recovery work without representing an independent user choice.
+
+**Disable dragging in Last updated.** Switching to Manual on a committed drag keeps the existing affordance and gives the edited order an explicit mode. A cancelled or ineffective drag leaves the selected mode unchanged.
+
+## Consequences
+
+Manual pauses automatic sorting of the current list. The default is Last updated, and reloads retain the selected mode and current manual positions. Chronological browsing gives up persistent drag exceptions. The view store owns manual Session positions but no activity timestamps; the Host Session-account order does not participate in this browser's display order.
+
+The archived sidebar note remains frozen historical evidence; its folding and Workspace-order decisions are outside this change. No active note owns the superseded shared-order policy.
+
+## Testing
+
+Component regressions cover older first arrivals, corrected and decreasing timestamps, delayed summaries, discarded-layout isolation, atomic mode switches, blank insertion and drag prevention, the first-prompt transition, collapsed-sidebar reconciliation, reloads, and automatic Manual selection on drag. The recorded-session Web scenario exercises the shipped composition with persisted positions, native dragging, grouped and flat views, a pinned blank, and reloads. Existing folding cases retain the provisional blank quota.

+ 39 - 0
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 独立于手动顺序派生 Workspace 最近更新顺序
+
+Status: implemented
+
+[English](2026-09-10-derived-workspace-recency.md) | 中文
+
+## 问题
+
+可编辑且按活动置顶的列表即使没有拖拽,也可能与显示的时间不符:较旧的 Session 迟到后,会被提升到已观察到的较新 Session 之前。重复输入同一份完整列表仍会保留这个逆序。历史上的[侧边栏顺序决策](../../archived/feature/2026-08-11-workspace-sidebar-order-and-folding.md)让手动排序和最近更新共用一份可编辑顺序,以便切换模式时保留位置。这个取舍无法满足按时间浏览的需要。
+
+## 决策
+
+最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
+
+只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
+
+当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
+
+持久化 key 保存分组方式、展开状态和手动排序位置。保留有效记账 key 时会移除持久化的已观察时间数据。时间准确性由当前元数据负责:缺少提示词元数据的冷摘要可能回退到创建时间,这与视图排序相互独立。
+
+## 考虑过的替代方案
+
+**保留活动提升,并在重连时再次排序。** 摘要迟到和元数据修正也会发生在一次连接内。仅在重连时修复仍会让时间顺序依赖观察先后。
+
+**切换模式时恢复独立的手动布局。** 用旧位置替换可见列表会让进入手动排序的结果出乎预期。在模式变化时重置布局,让手动排序只表示暂停当前排列。
+
+**持久化第二份时间顺序。** 当前时间戳已经决定该顺序。第二份账本保留了同步和恢复工作,却没有表达独立的用户选择。
+
+**禁止在最近更新中拖拽。** 在提交拖拽时切到手动排序,可以保留既有操作入口,并为编辑后的顺序提供明确模式。取消或未产生有效移动的拖拽保持所选模式不变。
+
+## 后果
+
+手动排序暂停当前列表的自动排列。默认模式为最近更新,重新加载保留所选模式和当前手动位置。按时间浏览放弃持久化的拖拽例外。视图存储拥有 Session 手动位置,但不拥有活动时间戳;Host Session 记账顺序不参与本浏览器的显示顺序。
+
+已归档的侧边栏记录保持为冻结的历史证据;其折叠和 Workspace 顺序决策不在本次修改范围内。没有活动记录拥有被替代的共享顺序策略。
+
+## 测试
+
+组件回归覆盖较旧记录首次到达、时间修正和回退、摘要迟到、已丢弃布局的隔离、原子模式切换、空白行插入与禁拖、首条提示词转换、侧边栏折叠时的对账、重新加载,以及拖拽后自动选中手动排序。录制会话 Web 场景在实际发布组合中验证持久化位置、原生拖拽、分组与单列表视图、固定首位的空白项及重新加载。既有折叠用例保留临时空白行配额。

+ 19 - 0
.github/workflows/ci.yml

@@ -325,6 +325,25 @@ jobs:
           if (( install_status != 0 )); then exit "$install_status"; fi
           exit "$sandbox_status"
 
+      # Blacksmith's Ubuntu HTTP archive endpoints can be unreachable while HTTPS works.
+      - name: Use HTTPS for Ubuntu archives on Blacksmith
+        if: vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+        run: |
+          sudo python3 - <<'PY'
+          from pathlib import Path
+          import re
+
+          apt = Path('/etc/apt')
+          sources = [apt / 'sources.list', apt / 'blacksmith-ubuntu-mirrors.txt',
+                     *(apt / 'sources.list.d').glob('*.list'),
+                     *(apt / 'sources.list.d').glob('*.sources')]
+          for source in sources:
+              if source.is_file():
+                  source.write_text(re.sub(
+                      r'http://((?:[a-z]{2}\.)?archive\.ubuntu\.com|security\.ubuntu\.com)/',
+                      r'https://\1/', source.read_text()))
+          PY
+
       - name: Install Playwright Chromium and hosted dependencies
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium

+ 13 - 2
apps/cli/tests/agent-team-headless.e2e.ts

@@ -54,7 +54,7 @@ describe('dsh run with Agent Teams enabled', () => {
       ].join('\n'))
       const launch = resolveExampleLaunch({
         srcBin: dshBinScript,
-        configArgs: ['--profile', 'headless', '请明确使用 Agent Teams,把调研和实现拆给两个 teammate,等待完成后汇总。'],
+        configArgs: ['--profile', 'headless', '请先运行 workflow 检查,再使用 Agent Teams 把调研和实现拆给两个 teammate,等待完成后汇总。'],
         tsconfigPath,
         env: {
           DSH_HOME: home,
@@ -85,9 +85,17 @@ describe('dsh run with Agent Teams enabled', () => {
 
       const files = (await readdir(sessions, { recursive: true }))
         .filter(file => file.endsWith('.jsonl'))
-      expect(files).toHaveLength(3)
+      expect(files).toHaveLength(4)
       const logs = await Promise.all(files.map(file => readFile(join(sessions, file), 'utf8')))
       const parsed = logs.map(records)
+      const workflowChild = parsed.find(log => log.some(record => record.type === 'subagent/descriptor'
+        && (record.data as { mode: string }).mode === 'one-shot'))
+      expect(workflowChild).toBeDefined()
+      expect(workflowChild!.find(record => record.type === 'subagent/descriptor')?.data)
+        .toMatchObject({ mode: 'one-shot', provider: 'spawn' })
+      expect(workflowChild!.filter(record => record.type === 'user/message'
+        && (record.data as { source: { kind: string } }).source.kind === 'user').map(record => record.data))
+        .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })])
       const root = parsed.find((log) => {
         const header = log[0]
         return header?.type === 'session' && typeof header.parentSession !== 'string'
@@ -107,6 +115,9 @@ describe('dsh run with Agent Teams enabled', () => {
       expect(toolNames).toContain('wait_agent')
       expect(toolNames).toContain('team_task_list')
       expect(toolNames).toContain('list_agents')
+      expect(toolNames).toContain('workflow')
+      expect(root!.find(record => record.type === 'tool-workflow/run-end')?.data)
+        .toMatchObject({ stopReason: 'completed' })
     } finally {
       await rm(cwd, { recursive: true, force: true })
     }

+ 2 - 11
apps/cli/tests/profiles/headless/team-snapshot.patch.yml

@@ -31,19 +31,10 @@
   disabled: true
 
 - id: tool-subagent
-  config:
-    provider: spawn
-    toolName: subagent
-    backgroundMode: one-shot
-    maxDepth: 1
+  disabled: true
 
 - id: tool-subagent-fork
-  config:
-    provider: fork
-    toolName: subagent_fork
-    backgroundMode: one-shot
-    enableRunInBackground: false
-    maxDepth: 1
+  disabled: true
 
 - insert:
     - id: agent-team

+ 19 - 5
apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs

@@ -134,6 +134,12 @@ function implementer(messages) {
 function lead(messages) {
   const names = calls(messages)
   const last = latestAssistantCalls(messages)
+  if (!names.includes('workflow')) {
+    return toolChunks([{ name: 'workflow', args: {
+      meta: { name: 'team-preflight', description: 'Check fresh workflow delegation alongside teammates.' },
+      script: 'return await agent("TEAM_WORKFLOW_CHILD");',
+    } }])
+  }
   const spawned = names.filter(name => name === 'spawn_teammate').length
   if (spawned === 0) {
     return toolChunks([{
@@ -174,12 +180,20 @@ function lead(messages) {
 
 class TeamFixtureAdapter extends LlmAdapter {
   async * stream(options) {
-    const userText = options.messages.flatMap(message => message.role === 'user'
-      ? message.content.filter(block => block.type === 'text').map(block => block.text)
-      : []).join('\n')
-    const chunks = userText.includes('RESEARCHER_MARK')
+    const tools = options.tools.map(tool => tool.name)
+    if (tools.includes('subagent') || tools.includes('subagent_fork')) {
+      throw new Error('Team profile exposes a direct subagent tool')
+    }
+    const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user')
+    const identity = initial?.content[0]?.text?.trimEnd()
+    if (identity !== 'TEAM_WORKFLOW_CHILD' && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) {
+      throw new Error('Team profile is missing teammate or workflow tools')
+    }
+    const chunks = identity === 'TEAM_WORKFLOW_CHILD'
+      ? textChunks('Fresh workflow child complete.')
+      : identity === '<system-reminder>\nYou are teammate "researcher".\n</system-reminder>'
       ? researcher(options.messages)
-      : userText.includes('IMPLEMENTER_MARK')
+      : identity === '<system-reminder>\nYou are teammate "implementer".\n</system-reminder>'
         ? implementer(options.messages)
         : lead(options.messages)
     for (const chunk of chunks) {

+ 33 - 2
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -719,7 +719,7 @@ describe('headless stream-json snapshots', () => {
       configPath: teamConfigPath,
       binArgs: [
         teamConfigPath,
-        '请明确使用 Agent Teams,把调研和实现拆给两个 teammate,等待完成后汇总。',
+        '请先运行 workflow 检查,再使用 Agent Teams 把调研和实现拆给两个 teammate,等待完成后汇总。',
       ],
       tsconfigPath,
       processTimeoutMs: 60_000,
@@ -732,6 +732,15 @@ describe('headless stream-json snapshots', () => {
         const parent = logs.find(log => typeof log.header.parentSession !== 'string')
         if (parent === undefined) throw new Error('Agent Teams snapshot did not persist its Lead')
         const rows = parseJsonl(parent.content)
+        const workflowChild = logs.find(log => parseJsonl(log.content).some(row => row.type === 'subagent/descriptor'
+          && (row.data as JsonObject).mode === 'one-shot'))
+        if (workflowChild === undefined) throw new Error('Team profile did not persist its workflow child')
+        const workflowRows = parseJsonl(workflowChild.content)
+        expect(workflowRows.find(row => row.type === 'subagent/descriptor')?.data)
+          .toMatchObject({ mode: 'one-shot', provider: 'spawn' })
+        expect(workflowRows.filter(row => row.type === 'user/message'
+          && ((row.data as JsonObject).source as JsonObject).kind === 'user').map(row => row.data))
+          .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })])
         const members = rows.filter(row => row.type === 'team/member')
           .map(row => ((row.data as JsonObject).member as JsonObject))
         const tasks = rows.filter(row => row.type === 'team/task')
@@ -780,9 +789,22 @@ describe('headless stream-json snapshots', () => {
           if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false
           return (JSON.parse(data.arguments) as JsonObject).action === 'complete'
         })
+        const identityReminders = logs.flatMap((log) => {
+          if (log === parent || log === workflowChild) return []
+          const initial = parseJsonl(log.content).find(row => row.type === 'user/message'
+            && ((row.data as JsonObject).source as JsonObject).kind === 'user')
+          if (initial === undefined) throw new Error('Teammate Session has no initial task')
+          const content = (initial.data as JsonObject).content as JsonObject[]
+          expect(content).toHaveLength(2)
+          const identity = content[0]!.text
+          if (typeof identity !== 'string') throw new Error('Teammate initial task has no identity text')
+          return [identity.trimEnd()]
+        }).sort()
         projection = {
           sessions: logs.length,
+          workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason,
           memberEdges: members.length,
+          identityReminders,
           activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(),
           tasks: latestTasks.map(task => ({
             subject: task.subject,
@@ -817,9 +839,17 @@ describe('headless stream-json snapshots', () => {
         ],
         "checkedRoster": true,
         "deliveredMessages": 2,
+        "identityReminders": [
+          "<system-reminder>
+      You are teammate "implementer".
+      </system-reminder>",
+          "<system-reminder>
+      You are teammate "researcher".
+      </system-reminder>",
+        ],
         "memberEdges": 4,
         "queuedMessages": 2,
-        "sessions": 3,
+        "sessions": 4,
         "steerEvidence": {
           "completedAfterMessage": true,
           "enteredOpenTurn": true,
@@ -839,6 +869,7 @@ describe('headless stream-json snapshots', () => {
           },
         ],
         "waited": true,
+        "workflowStopReason": "completed",
       }
     `)
   }, 75_000)

+ 2 - 8
apps/web/tests/agent-team-panel.overlay.yml

@@ -7,16 +7,10 @@
   disabled: true
 
 - id: tool-subagent
-  config:
-    provider: spawn
-    toolName: subagent
-    backgroundMode: one-shot
+  disabled: true
 
 - id: tool-subagent-fork
-  config:
-    provider: fork
-    toolName: subagent_fork
-    backgroundMode: one-shot
+  disabled: true
 
 - insert:
     - id: agent-team

+ 1 - 1
apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md

@@ -2,5 +2,5 @@
   - treeitem "workspace" [expanded]:
     - img
     - text: workspace
-  - treeitem "1 subagent running Delegate a background job. now"
   - treeitem "New Session" [selected]
+  - treeitem "1 subagent running Delegate a background job. now"

+ 56 - 7
apps/web/tests/message-actions.e2e.ts

@@ -28,13 +28,14 @@ const SEED_ID = 'message-actions-web-e2e'
 const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
 const MID_TURN_TEXT = 'I will read both files before answering.'
 const SECOND_PROMPT = 'Now give the final answer.'
+const NEXT_PROMPT = 'Keep this later input in the original conversation.'
 
 /**
  * Adapt the borrowed recording into response -> tools -> interrupted Think,
- * followed by one ordinary completed response. The first response keeps
+ * followed by two ordinary completed responses. The first response keeps
  * copy/clock but is not a legal branch point; the second is the real turn tail.
  * @param raw - Recorded seeded-history JSONL.
- * @returns A contiguous, closed two-turn fixture.
+ * @returns A contiguous, closed three-turn fixture.
  */
 function completedTailFixture(raw: string): string {
   const decoded = parseSeedFixture(raw)
@@ -137,6 +138,48 @@ function completedTailFixture(raw: string): string {
     }),
     at({ type: 'step/end', data: { turn: 2, step: 1 } }),
     at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }),
+    at({
+      type: 'agent/inbox/spliced',
+      data: {
+        target: 'next-turn', start: 0,
+        inserted: [{
+          role: 'user', id: '{{message:100}}', source: { kind: 'user' },
+          content: [{ type: 'text', text: NEXT_PROMPT }],
+        }],
+      },
+    }),
+    at({ type: 'turn/start', data: { turn: 3 } }),
+    at({
+      type: 'agent/inbox/spliced',
+      data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] },
+    }),
+    at({ type: 'step/start', data: { turn: 3, step: 1 } }),
+    at({
+      type: 'user/message', surfaceOp: 'append',
+      data: {
+        role: 'user', id: '{{message:100}}', source: { kind: 'user' },
+        content: [{ type: 'text', text: NEXT_PROMPT }],
+      },
+    }),
+    at({
+      type: 'assistant/message', surfaceOp: 'append',
+      data: {
+        turn: 3, step: 1,
+        message: {
+          role: 'assistant', id: '{{message:101}}',
+          source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+          content: [{ type: 'text', text: 'ORIGINAL ONLY' }],
+        },
+        stream: [
+          { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+          { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['ORIGINAL ONLY'] },
+          { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'ORIGINAL ONLY' } } },
+          { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+        ],
+      },
+    }),
+    at({ type: 'step/end', data: { turn: 3, step: 1 } }),
+    at({ type: 'turn/end', data: { turn: 3, reason: { kind: 'completed' } } }),
   ]
   return renderSeedFixture(decoded.headerLine, [...kept, ...tail])
 }
@@ -154,7 +197,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
     await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
     const raw = completedTailFixture(await readFile(SEED, 'utf8'))
-    expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
+    expect(fixtureUserPrompts(raw), 'adapted seed must carry all prompts').toEqual([PROMPT, SECOND_PROMPT, NEXT_PROMPT])
     expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
       ? [event.data.reason]
       : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
@@ -193,11 +236,11 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
     await copyButtons.first().focus()
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
-    await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(2)
+    await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(3)
     await expect.poll(
       () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
       { timeout: 5_000 },
-    ).toEqual(['true', null])
+    ).toEqual(['true', null, null])
     await branchButtons.first().focus()
     await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
       .toBe('Available only on the last message of a completed turn')
@@ -219,12 +262,18 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
 
   it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
-    // The last message action belongs to the completed second-turn assistant.
-    await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
+    // The second answer is followed by another completed user turn in the source.
+    await page.getByRole('button', { name: 'Branch into a new conversation' }).nth(1).click()
     await expect.poll(
       () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
       { timeout: 15_000 },
     ).toBeDefined()
+    const child = scaffold.ctx.agents.list().find(agent =>
+      agent.session.header.parentSession === SessionId(SEED_ID))!
+    expect(child.inbox.nextTurn).toEqual([])
+    expect(child.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
+      && event.data.inserted.some(message => message.content.some(part =>
+        part.type === 'text' && part.text === NEXT_PROMPT)))).toBe(false)
     await expect.poll(
       () => page.locator('[role="treeitem"]').count(),
       { timeout: 10_000 },

+ 167 - 0
apps/web/tests/workspace-recency.e2e.ts

@@ -0,0 +1,167 @@
+/** Recency and saved manual order through the shipped Web composition. */
+import { readFile } from 'node:fs/promises'
+import { basename, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page, type WebSocketRoute } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workspace-recency', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
+const MODE = webSnapshotMode()
+const TITLES = ['Newest conversation', 'Middle conversation', 'Oldest conversation']
+
+describe('web e2e: workspace recency', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let socket: WebSocketRoute
+  let holdWorkspace = false
+  let releaseWorkspace: (() => void) | undefined
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    const fixture = await readFile(SEED, 'utf8')
+    const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
+    const now = Date.now()
+    const ids = []
+    for (const [index, title] of TITLES.entries()) {
+      const id = await seedSession(scaffold, fixture, `workspace-recency-${index}`, undefined, {
+        createdAt: now - (index + 1) * 60_000,
+      })
+      await workspace.attachSession(id)
+      await scaffold.ctx.sessionController.rename({ sessionId: id, title })
+      ids.push(id)
+    }
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.routeWebSocket('**/api/remote.mux', (route) => {
+      socket = route
+      const server = route.connectToServer()
+      let heldStream: string | undefined
+      const pending: (string | Buffer)[] = []
+      server.onMessage((message) => {
+        const frame = JSON.parse(String(message)) as {
+          type: string
+          streamId: string
+          value?: { type: string; value?: { archivedSessionIds?: string[] } }
+        }
+        if (holdWorkspace && frame.type === 'item' && frame.value?.type === 'baseline'
+          && frame.value.value?.archivedSessionIds !== undefined) {
+          heldStream = frame.streamId
+          releaseWorkspace = () => {
+            heldStream = undefined
+            for (const buffered of pending.splice(0)) route.send(buffered)
+          }
+        }
+        if (frame.streamId === heldStream) pending.push(message)
+        else route.send(message)
+      })
+    })
+    await page.clock.setFixedTime(now)
+    tripwire = watchConsole(page)
+    await page.addInitScript(({ account, ids }) => {
+      if (localStorage.getItem('dsh.workspace.view.v5') !== null) return
+      localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: ids[0] }))
+      localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({
+        groupBy: 'workspace', orderBy: 'updated', groupExpansion: { [account]: true },
+        sessionOrderByAccount: { [account]: [...ids].reverse(), __flat_session_order__: [...ids].reverse() },
+      }))
+    }, { account: workspace.id, ids })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('pauses current recency in Manual, preserves drags on reload, and resets on Last updated', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-recency'))
+    const titles = () => page.locator('[role="treeitem"]:not([aria-expanded]) [class*="title"]').allTextContents()
+    const pick = async (name: string): Promise<void> => {
+      await page.getByRole('button', { name: 'View options' }).click()
+      await page.getByRole('menuitem', { name, exact: true }).click()
+    }
+    const captureSidebar = async (): Promise<string> => {
+      await page.getByRole('button', { name: 'View options' }).hover()
+      await expect.poll(() => page.getByRole('button', { name: /^Session actions for / }).count()).toBe(0)
+      return captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
+    }
+    await expect.poll(titles).toEqual(TITLES)
+    await compareOrRefreshGolden(
+      join(SNAPSHOT_DIR, 'sidebar.expected.md'),
+      await captureSidebar(), MODE,
+    )
+    await pick('Manual')
+    await expect.poll(titles).toEqual(TITLES)
+    await pick('Last updated')
+    await expect.poll(titles).toEqual(TITLES)
+    const source = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[2]!, { exact: true }) })
+    const target = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[0]!, { exact: true }) })
+    await source.dragTo(target, { targetPosition: { x: 30, y: 3 } })
+    const dragged = [TITLES[2], TITLES[0], TITLES[1]]
+    await expect.poll(titles).toEqual(dragged)
+    const manualWarningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(titles).toEqual(dragged)
+    acknowledgeReloadConnectionLoss(tripwire, manualWarningStart)
+    await pick('Last updated')
+    await expect.poll(titles).toEqual(TITLES)
+    await pick('In one list')
+    await expect.poll(titles).toEqual(TITLES)
+    const recencyWarningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(titles).toEqual(TITLES)
+    acknowledgeReloadConnectionLoss(tripwire, recencyWarningStart)
+    await pick('Manual')
+    await expect.poll(titles).toEqual(TITLES)
+    await pick('Last updated')
+    await pick('WorkSpace')
+    await pick('Manual')
+    const reconnectWarningStart = tripwire.warnings.length
+    holdWorkspace = true
+    await socket.close()
+    await expect.poll(() => releaseWorkspace !== undefined).toBe(true)
+    const workspaceTitle = basename(scaffold.workspaceCwd)
+    await page.getByRole('treeitem').filter({ has: page.getByText(workspaceTitle, { exact: true }) }).hover()
+    await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
+    await pick('In one list')
+    await expect.poll(titles).toEqual(['New Session', ...TITLES])
+    await expect.poll(() => page.evaluate(() => {
+      const { sessionId } = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string }
+      const { sessionOrderByAccount } = JSON.parse(localStorage.getItem('dsh.workspace.view.v5')!) as {
+        sessionOrderByAccount: Record<string, string[]>
+      }
+      return sessionOrderByAccount.__flat_session_order__?.[0] === sessionId
+    })).toBe(true)
+    holdWorkspace = false
+    releaseWorkspace!()
+    await pick('WorkSpace')
+    acknowledgeReloadConnectionLoss(tripwire, reconnectWarningStart)
+    await expect.poll(titles).toEqual(['New Session', ...TITLES])
+    await compareOrRefreshGolden(
+      join(SNAPSHOT_DIR, 'manual-blank.expected.md'),
+      await captureSidebar(), MODE,
+    )
+    const blank = page.getByRole('treeitem').filter({ has: page.getByText('New Session', { exact: true }) })
+    await expect.poll(() => blank.getAttribute('draggable')).toBe('false')
+    const blankWarningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(titles).toEqual(['New Session', ...TITLES])
+    acknowledgeReloadConnectionLoss(tripwire, blankWarningStart)
+    await pick('Last updated')
+    await expect.poll(titles).toEqual(['New Session', ...TITLES])
+    await pick('Manual')
+    await expect.poll(titles).toEqual(['New Session', ...TITLES])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['sidebar.expected.md', 'manual-blank.expected.md'])
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -56,6 +56,7 @@
     "tests/remote-welcome.e2e.ts",
     "tests/workspace-new-session-folding.e2e.ts",
     "tests/workspace-management.e2e.ts",
+    "tests/workspace-recency.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/hmr-live.e2e.ts",
     "tests/preview-boot.e2e.ts",

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: 768c8704775d422c9c48d7d40a58d589f715b718
-README.zh.md: 3c1de73d13678b15af233196894b51fffa952f67
+README.md: 15f16200d66949163ac8c1e5dbb2994756598dfc
+README.zh.md: 63641025575b61a38d1e06485dc5ea546101f5b6

+ 2 - 0
packages/api/session-controller/README.md

@@ -37,6 +37,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio
 
 The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
 
+Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
+
 <a id="session-media-references"></a>
 ## Session media references
 

+ 2 - 0
packages/api/session-controller/README.zh.md

@@ -37,6 +37,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 
 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
 
+分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
+
 <a id="session-media-references"></a>
 ## 会话媒体引用
 

+ 1 - 4
packages/api/session-controller/src/commands.ts

@@ -240,10 +240,7 @@ export class SessionCommandController {
         { sessionId: request.sessionId },
       )
     }
-    let cut = SessionLogOffset(boundary.seq + 1)
-    while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') {
-      cut = SessionLogOffset(cut + 1)
-    }
+    const cut = SessionLogOffset(boundary.seq + 1)
     let workspace: Workspace | undefined
     try {
       workspace = await this.forkWorkspace(source.header)

+ 65 - 3
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
+import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -88,6 +90,53 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
 })
 
 describe('sessions.fork', () => {
+  it.each(['message', 'turn-end', 'omitted', 'past-end'] as const)(
+    'excludes the next user input when forking at %s', async (anchor) => {
+      const ctx = new Context()
+      try {
+        await mountAgentLoopTestDependencies(ctx)
+        const harness = await mountAgentLoopTestHarness(ctx)
+        const adapter = new MockAdapter(Array.from({ length: 4 }, () => textResponse('reply')))
+        ctx.llm.registerAdapter(['mock'], adapter)
+        ctx.provide('workspaceRegistry', { list: () => [] } as never)
+        const source = await harness.create(sid('source'), { provider: 'mock', model: 'mock' })
+        const message = (text: string) => createUserMessage({
+          content: [{ type: 'text', text }], source: { kind: 'user' },
+        })
+        source.followup(message('A'))
+        await source.whenIdle()
+        const boundary = source.session.snapshotEvents().at(-1)!.seq
+        if (anchor === 'message' || anchor === 'turn-end') {
+          source.followup(message('B'))
+          await source.whenIdle()
+        } else {
+          source.inbox.append('next-turn', message('B'))
+        }
+        const original = source.session.snapshotEvents()
+        const atSeq = anchor === 'omitted' ? undefined
+          : anchor === 'past-end' ? source.session.seq + 1
+            : anchor === 'turn-end' ? boundary
+              : original.find(event => event.type === 'assistant/message')!.seq
+        const response = await createSessionTestRemote(ctx, {
+          defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
+        }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
+        if (!response.ok) throw response.error
+        const child = ctx.agents.get(response.value.sessionId)!
+        const requestCount = adapter.requests.length
+        child.followup(message('C'))
+        await child.whenIdle()
+        const userTexts = child.session.deriveMessages().flatMap(item => item.role === 'user'
+          ? item.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])
+        expect(userTexts).toEqual(['A', 'C'])
+        expect(adapter.requests.slice(requestCount)).toHaveLength(1)
+        expect(child.session.inheritedEventCount).toBe(boundary + 1)
+        expect(source.session.snapshotEvents()).toEqual(original)
+      } finally {
+        await ctx.fiber.dispose()
+      }
+    },
+  )
+
   it('cuts at the anchored completed turn and records lineage and cwd', async () => {
     const ctx = await composed()
     const source = liveAgent(ctx, 'session-source', 2)
@@ -268,9 +317,10 @@ describe('sessions.fork', () => {
     await ctx.fiber.dispose()
   })
 
-  it('installs the latest logged model selection before the child can run', async () => {
+  it('inherits model selection through the completed turn and excludes later changes', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-routed', 1)
+    const source = liveAgent(ctx, 'session-routed', 0)
+    source.append('turn/start', { turn: 1 })
     source.append('request/header', {
       header: {
         config: {
@@ -281,11 +331,23 @@ describe('sessions.fork', () => {
       },
       reason: 'initial',
     })
-    const response = await remote(ctx).fork(request({ sessionId: source.id }))
+    const boundary = source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const inherited = source.snapshotEvents()
+    source.append('session/title', {
+      title: 'Title after the selected turn', messageSeqs: [], source: { kind: 'user' },
+    })
+    source.append('turn/start', { turn: 2 })
+    source.append('request/header', {
+      header: { config: { provider: 'later-provider', model: 'later-model' } },
+      reason: 'change',
+    })
+    source.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
     expect(response.ok).toBe(true)
     if (!response.ok) return
     const child = ctx.agents.get(response.value.sessionId)
     if (child === undefined) throw new Error('fork did not publish the child agent')
+    expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
     const assembly = await child.ctx.systemPrompt.assemble()
     expect(assembly.variables).toMatchObject({
       provider: 'inherited-provider',

+ 2 - 2
packages/client/connection/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/connection/README.md
-README.md: 865c894403d2177e15085d0616289cf8950b555d
-README.zh.md: d3d0a62035b1770ecc1b9f81aa775dad0c86cb11
+README.md: 606f27f37a80c11c105a1431858efae7b8e9d86a
+README.zh.md: d7885ad873fb1d7d82c7a613c8f66bc7356b2542

+ 2 - 0
packages/client/connection/README.md

@@ -27,6 +27,8 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
 
 The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
 
+The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package).
+
 -----
 
 <a id="browser-authentication-and-request-trust"></a>

+ 2 - 0
packages/client/connection/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
 
+浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。
+
 -----
 
 <a id="browser-authentication-and-request-trust"></a>

+ 1 - 2
packages/client/connection/src/client/fixture.ts

@@ -3249,8 +3249,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           details: { sessionId },
         })
       }
-      let cut = boundary.seq + 1
-      while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
+      const cut = boundary.seq + 1
       const child: FixtureSessionSummary = {
         sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
         parentSessionId: sessionId,

+ 29 - 0
packages/client/connection/tests/fixture.client.spec.ts

@@ -1635,6 +1635,35 @@ describe('fixture Connection RPC', () => {
     vi.unstubAllGlobals()
   })
 
+  it.each(['turn-end', 'omitted', 'past-end'] as const)('forks through the completed turn for a %s anchor', async (anchor) => {
+    vi.stubGlobal('__fxTiming', (globalThis as Record<string, unknown>).__fxTiming)
+    const { rpc } = createFixtureFaces()
+    const sessions = createSessionClient(rpc)
+    const sourceId = sid('fx-alpha')
+    const initial = await sessions.history({ sessionId: sourceId, maxMessages: 1000 })
+    if (!initial.result.ok) throw new Error('source history failed')
+    const events = historyEvents(initial.result.value.records)
+    const boundary = events.findLast(event => event.type === 'turn/end')
+    if (boundary === undefined) throw new Error('source has no completed turn')
+    expect(initial.result.value.hasMore).toBe(false)
+    expect((await sessions.rename({ sessionId: sourceId, title: 'Title after the selected turn' })).result.ok).toBe(true)
+    const source = await sessions.history({ sessionId: sourceId, maxMessages: 1000 })
+    if (!source.result.ok) throw new Error('source history after rename failed')
+    const fork = await rpc.call('/api', 'session/fork', {
+      args: { request: {
+        sessionId: sourceId,
+        ...(anchor === 'omitted' ? {} : { atSeq: anchor === 'turn-end' ? boundary.seq : events.length + 100 }),
+      } },
+    })
+    if (!fork.ok) throw new Error('fork failed')
+    const childId = (fork.value as { sessionId: SessionId }).sessionId
+    const child = await sessions.history({ sessionId: childId, maxMessages: 1000 })
+    if (!child.result.ok) throw new Error('child history failed')
+    expect(child.result.value.hasMore).toBe(false)
+    expect(historyEvents(child.result.value.records)).toEqual(events.slice(0, boundary.seq + 1))
+    expect(await sessions.history({ sessionId: sourceId, maxMessages: 1000 })).toMatchObject({ result: source.result })
+  })
+
   it('covers the migrated Remote dispatch table', async () => {
     const rpc = createFixtureConnectionRpc()
     const sessions = createSessionClient(rpc)

+ 285 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx

@@ -0,0 +1,285 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import clsx from 'clsx'
+import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
+import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
+import css from './ConversationRoot.module.css'
+
+type ConversationContentProps = Omit<ConversationSlotProps, 'useSession' | 'useConversation'> & {
+  session: SessionSnapshot | undefined
+  phase: 'settling' | 'hero' | 'active'
+  hero: boolean
+  onHandleStart: () => number
+  onHandleDrag: (width: number) => void
+  onHandleCommit: (width: number) => void
+  onHandleEnd: () => void
+}
+
+/** 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>) => {
+    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()
+  }, [])
+
+  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}
+    />
+  )
+}
+
+/**
+ * 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.
+ */
+export function ConversationContent({
+  sessionId, session, phase, hero, useSessions, useSessionPendingInteraction,
+  useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
+  selectWorkspace, t, onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
+}: ConversationContentProps) {
+  const pendingInteraction = useSessionPendingInteraction(snapshot =>
+    sessionId === undefined ? undefined : snapshot.get(sessionId))
+  const inputState = useInput(s => s)
+  const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
+  const workspaces = useWorkspaces(s => s)
+  // A plugin this package cannot import (ui-model-selection) says this session cannot
+  // send; its reason is already localized by whoever raised it.
+  const composerBlock = useComposerBlock(block => block)
+
+  const [pickerOpen, setPickerOpen] = useState(false)
+  const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
+  const pickerAnchor = useRef<HTMLButtonElement>(null)
+
+  // Publishes the two live measurements floating View chrome reads off the
+  // scroll body: the seat's height as --dsh-composer-height, so controls clear
+  // the composer as it grows, and the scrollport's own height as
+  // --dsh-conversation-viewport-height, so a control can sit in the band the
+  // seat leaves visible. Callback ref, not an effect; stable identity prevents
+  // observer churn while the first blank session fills the resident body
+  // outlet.
+  const seatObserver = useRef<ResizeObserver | null>(null)
+  const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
+    seatObserver.current?.disconnect()
+    seatObserver.current = null
+    const scroller = seat?.parentElement ?? null
+    if (seat === null || scroller === null) return
+    seatObserver.current = new ResizeObserver(() => {
+      scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
+      scroller.style.setProperty(
+        '--dsh-conversation-viewport-height',
+        `${scroller.clientHeight}px`,
+      )
+    })
+    seatObserver.current.observe(seat)
+    seatObserver.current.observe(scroller)
+  }, [])
+
+  const sessionWorkspace = sessionId === undefined
+    ? undefined
+    : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
+  const pendingWorkspace = workspaces.items.find(
+    workspace => workspace.workspaceId === pendingWorkspaceId,
+  )
+
+  // Clear the pending pick once the session lands in it, or when the picked
+  // workspace disappears from a ready list (deleted from the sidebar).
+  useEffect(() => {
+    if (pendingWorkspaceId === undefined) return
+    if (sessionWorkspace?.workspaceId === pendingWorkspaceId
+      || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
+      setPendingWorkspaceId(undefined)
+    }
+  }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
+
+  const zone: InputZone | undefined =
+    session === undefined || inputState === undefined ? undefined : { session, input: inputState }
+
+  // The chip is a selector; label resolution walks the flow top-down:
+  //   1. a just-picked workspace (pending) → its title;
+  //   2. cold start, no session yet → placeholder ("Choose workspace");
+  //   3. the blank session's workspace is in the list → its title;
+  //   4. list still loading → cwd folder name bridges so the title does not
+  //      flash on refresh (empty cwd → placeholder);
+  //   5. list ready but no owning workspace (deleted from the sidebar) →
+  //      placeholder, never the deleted folder's name via cwd.
+  const chipTitle = pendingWorkspace?.title
+    ?? (sessionId === undefined
+      ? undefined
+      : sessionWorkspace?.title
+        ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
+          ? undefined
+          : workspaceLabel(cwd)))
+
+  const heroWorkspaceRow = (
+    <div className={css.heroWorkspaceRow}>
+      <WorkspaceChip
+        buttonRef={pickerAnchor}
+        label={chipTitle}
+        menuOpen={pickerOpen}
+        onClick={() => { setPickerOpen(open => !open) }}
+        t={t}
+      />
+      {renderSlot('conversation.hero.workspace', {
+        open: pickerOpen,
+        anchorRef: pickerAnchor,
+        selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
+        onPick: (workspaceId) => {
+          setPickerOpen(false)
+          setPendingWorkspaceId(workspaceId)
+          void selectWorkspace(workspaceId).catch(() => {
+            setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
+          })
+        },
+        onClose: () => { setPickerOpen(false) },
+      })}
+      {renderSlot('conversation.hero.agentPreset', {})}
+    </div>
+  )
+
+  // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel
+  // together: no workspace picked yet (cold start, no session at all), or a
+  // blank session whose workspace vanished (deleted from the sidebar). The
+  // bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
+  // not a different tree, so the textarea DOM survives the transition.
+  const inert = sessionId === undefined || (hero && chipTitle === undefined)
+  // A raised block is the same inert posture with the blocker's own reason:
+  // one disabled textarea, never a second tree. The no-workspace state wins
+  // when both hold — picking a workspace is the earlier prerequisite.
+  const blocked = !inert && composerBlock !== undefined
+  const inputBar = renderSlot('conversation.composer.bar', {
+    variant: hero ? 'hero' : 'composer',
+    ...(inert
+      ? {
+        disabled: true,
+        placeholder: t('placeholder.workspace'),
+        workspacePickerOpen: pickerOpen,
+        onRequestWorkspace: () => { setPickerOpen(true) },
+      }
+      : blocked
+        // `blocked`, not `disabled`: the bar refuses input either way, but a
+        // block keeps the model seat live because choosing a model is how the
+        // user clears it.
+        ? { blocked: composerBlock, placeholder: composerBlock.reason }
+        : hero ? { placeholder: t('placeholder.hero') } : {}),
+  })
+
+  const composerBar = (
+    <div className={clsx(css.composerStack, hero && css.composerHero)}>
+      {hero && <HeroShell t={t} renderSlot={renderSlot} />}
+      {hero && heroWorkspaceRow}
+      {zone !== undefined && renderSlot('conversation.input.dock', zone)}
+      {inputBar}
+    </div>
+  )
+
+  const composer = renderSlotChain(
+    'conversation.composer',
+    { sessionId, session, pendingInteraction },
+    { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true },
+  )
+
+  // Sticky wraps the whole chain output (fallback + elected overlay), not
+  // only `.composerStack`: overlay:true renders those as siblings, and sticky
+  // on the fallback alone would leave a business-owned takeover at the content
+  // end off-screen when the user is not pinned to the floor.
+  const composerSeat = (
+    <div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
+      {composer}
+    </div>
+  )
+
+  return (
+    <div className={css.body}>
+      <div className={css.scrollBody} data-conversation-scroll="">
+        {sessionId === undefined ? null : renderSlot('conversation.session', {})}
+        {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}
+        />
+      ))}
+    </div>
+  )
+}

+ 146 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx

@@ -0,0 +1,146 @@
+import { useCallback, useRef } from 'react'
+import type { ConversationSlotProps } from '../contract/slots.ts'
+import { conversationPhase } from '../contract/snapshot.ts'
+import { ConversationContent } from './ConversationContent.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.
+ * @returns the unchanged root, Header, content, and width-control subtree.
+ */
+export function ConversationMainPanel(props: ConversationSlotProps) {
+  const { sessionId, useSession, useSessions, useConversation, renderSlot } = props
+  const session = useSession(s => s)
+  const conversation = useConversation(s => s)
+  const shellPhase = session === undefined || conversation === undefined
+    ? 'blank'
+    : conversationPhase(session, conversation)
+  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).
+  // Exemption: a session the list summary already proves blank can only
+  // land on the hero, so hiding would blank the column for the whole
+  // history round-trip (the startup auto-selection flash) for nothing.
+  // The exemption is deliberately open-state-wide, not loading-only: a
+  // summary-blank session is the hero before its open starts (`cold`) and
+  // after one fails (`error`) for the same reason — there is no history.
+  // A restored continuable subagent also stays settled until its eagerly
+  // loaded parent catalog establishes availability. This keeps the composer
+  // hidden instead of briefly rendering the parent-offline takeover.
+  const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable'
+    && session.subagent.parentAvailable === undefined
+  const settling = sessionId !== undefined && (
+    (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true)
+    || parentAvailabilityPending
+  )
+  const hero = sessionId === undefined
+    || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true))
+  const phase = settling ? 'settling' : hero ? 'hero' : 'active'
+
+  return (
+    <div ref={rootResizeRef} className={css.root} data-phase={phase}>
+      {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
+      <ConversationContent
+        {...props}
+        session={session}
+        phase={phase}
+        hero={hero}
+        onHandleStart={onHandleStart}
+        onHandleDrag={onHandleDrag}
+        onHandleCommit={onHandleCommit}
+        onHandleEnd={onHandleEnd}
+      />
+    </div>
+  )
+}

+ 4 - 386
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -2,394 +2,12 @@
 // chain, AND the composer bar (session-maybe slot) stay mounted across
 // no-session/session transitions — the bar renders inert via owner props.
 
-import { useCallback, useEffect, useRef, useState } from 'react'
-import clsx from 'clsx'
-import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
-import { conversationPhase } from '../contract/snapshot.ts'
-import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
-import css from './ConversationRoot.module.css'
+import type { ConversationSlotProps } from '../contract/slots.ts'
+import { ConversationMainPanel } from './ConversationMainPanel.tsx'
 
 /** Full props composed from the slot contract. */
 export type ConversationRootProps = ConversationSlotProps
 
-/** 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))
-}
-
-/** 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>) => {
-    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()
-  }, [])
-
-  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}
-    />
-  )
-}
-
-export function ConversationRoot({
-  sessionId, useSession, useSessions, useSessionPendingInteraction,
-  useWorkspaces, useConversation, useInput, useComposerBlock,
-  renderSlot, renderSlotChain, selectWorkspace, t,
-}: ConversationRootProps) {
-  const session = useSession(s => s)
-  const pendingInteraction = useSessionPendingInteraction(snapshot =>
-    sessionId === undefined ? undefined : snapshot.get(sessionId))
-  const conversation = useConversation(s => s)
-  const shellPhase = session === undefined || conversation === undefined
-    ? 'blank'
-    : conversationPhase(session, conversation)
-  const openState = session?.openState
-  const inputState = useInput(s => s)
-  const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
-  const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
-  const workspaces = useWorkspaces(s => s)
-  // A plugin this package cannot import (ui-model-selection) says this session cannot
-  // send; its reason is already localized by whoever raised it.
-  const composerBlock = useComposerBlock(block => block)
-
-  const [pickerOpen, setPickerOpen] = useState(false)
-  const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
-  const pickerAnchor = useRef<HTMLButtonElement>(null)
-
-  // Publishes the two live measurements floating View chrome reads off the
-  // scroll body: the seat's height as --dsh-composer-height, so controls clear
-  // the composer as it grows, and the scrollport's own height as
-  // --dsh-conversation-viewport-height, so a control can sit in the band the
-  // seat leaves visible. Callback ref, not an effect; stable identity prevents
-  // observer churn while the first blank session fills the resident body
-  // outlet.
-  const seatObserver = useRef<ResizeObserver | null>(null)
-  const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
-    seatObserver.current?.disconnect()
-    seatObserver.current = null
-    const scroller = seat?.parentElement ?? null
-    if (seat === null || scroller === null) return
-    seatObserver.current = new ResizeObserver(() => {
-      scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
-      scroller.style.setProperty(
-        '--dsh-conversation-viewport-height',
-        `${scroller.clientHeight}px`,
-      )
-    })
-    seatObserver.current.observe(seat)
-    seatObserver.current.observe(scroller)
-  }, [])
-
-  // 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])
-
-  const sessionWorkspace = sessionId === undefined
-    ? undefined
-    : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
-  const pendingWorkspace = workspaces.items.find(
-    workspace => workspace.workspaceId === pendingWorkspaceId,
-  )
-
-  // Clear the pending pick once the session lands in it, or when the picked
-  // workspace disappears from a ready list (deleted from the sidebar).
-  useEffect(() => {
-    if (pendingWorkspaceId === undefined) return
-    if (sessionWorkspace?.workspaceId === pendingWorkspaceId
-      || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
-      setPendingWorkspaceId(undefined)
-    }
-  }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
-
-  // 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).
-  // Exemption: a session the list summary already proves blank can only
-  // land on the hero, so hiding would blank the column for the whole
-  // history round-trip (the startup auto-selection flash) for nothing.
-  // The exemption is deliberately open-state-wide, not loading-only: a
-  // summary-blank session is the hero before its open starts (`cold`) and
-  // after one fails (`error`) for the same reason — there is no history.
-  // A restored continuable subagent also stays settled until its eagerly
-  // loaded parent catalog establishes availability. This keeps the composer
-  // hidden instead of briefly rendering the parent-offline takeover.
-  const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable'
-    && session.subagent.parentAvailable === undefined
-  const settling = sessionId !== undefined && (
-    (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true)
-    || parentAvailabilityPending
-  )
-  const hero = sessionId === undefined
-    || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true))
-  const zone: InputZone | undefined =
-    session === undefined || inputState === undefined ? undefined : { session, input: inputState }
-
-  // The chip is a selector; label resolution walks the flow top-down:
-  //   1. a just-picked workspace (pending) → its title;
-  //   2. cold start, no session yet → placeholder ("Choose workspace");
-  //   3. the blank session's workspace is in the list → its title;
-  //   4. list still loading → cwd folder name bridges so the title does not
-  //      flash on refresh (empty cwd → placeholder);
-  //   5. list ready but no owning workspace (deleted from the sidebar) →
-  //      placeholder, never the deleted folder's name via cwd.
-  const chipTitle = pendingWorkspace?.title
-    ?? (sessionId === undefined
-      ? undefined
-      : sessionWorkspace?.title
-        ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
-          ? undefined
-          : workspaceLabel(cwd)))
-
-  const heroWorkspaceRow = (
-    <div className={css.heroWorkspaceRow}>
-      <WorkspaceChip
-        buttonRef={pickerAnchor}
-        label={chipTitle}
-        menuOpen={pickerOpen}
-        onClick={() => { setPickerOpen(open => !open) }}
-        t={t}
-      />
-      {renderSlot('conversation.hero.workspace', {
-        open: pickerOpen,
-        anchorRef: pickerAnchor,
-        selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
-        onPick: (workspaceId) => {
-          setPickerOpen(false)
-          setPendingWorkspaceId(workspaceId)
-          void selectWorkspace(workspaceId).catch(() => {
-            setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
-          })
-        },
-        onClose: () => { setPickerOpen(false) },
-      })}
-      {renderSlot('conversation.hero.agentPreset', {})}
-    </div>
-  )
-
-  // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel
-  // together: no workspace picked yet (cold start, no session at all), or a
-  // blank session whose workspace vanished (deleted from the sidebar). The
-  // bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
-  // not a different tree, so the textarea DOM survives the transition.
-  const inert = sessionId === undefined || (hero && chipTitle === undefined)
-  // A raised block is the same inert posture with the blocker's own reason:
-  // one disabled textarea, never a second tree. The no-workspace state wins
-  // when both hold — picking a workspace is the earlier prerequisite.
-  const blocked = !inert && composerBlock !== undefined
-  const inputBar = renderSlot('conversation.composer.bar', {
-    variant: hero ? 'hero' : 'composer',
-    ...(inert
-      ? {
-        disabled: true,
-        placeholder: t('placeholder.workspace'),
-        workspacePickerOpen: pickerOpen,
-        onRequestWorkspace: () => { setPickerOpen(true) },
-      }
-      : blocked
-        // `blocked`, not `disabled`: the bar refuses input either way, but a
-        // block keeps the model seat live because choosing a model is how the
-        // user clears it.
-        ? { blocked: composerBlock, placeholder: composerBlock.reason }
-        : hero ? { placeholder: t('placeholder.hero') } : {}),
-  })
-
-  const composerBar = (
-    <div className={clsx(css.composerStack, hero && css.composerHero)}>
-      {hero && <HeroShell t={t} renderSlot={renderSlot} />}
-      {hero && heroWorkspaceRow}
-      {zone !== undefined && renderSlot('conversation.input.dock', zone)}
-      {inputBar}
-    </div>
-  )
-
-  const phase = settling ? 'settling' : hero ? 'hero' : 'active'
-  const composer = renderSlotChain(
-    'conversation.composer',
-    { sessionId, session, pendingInteraction },
-    { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true },
-  )
-
-  // Sticky wraps the whole chain output (fallback + elected overlay), not
-  // only `.composerStack`: overlay:true renders those as siblings, and sticky
-  // on the fallback alone would leave a business-owned takeover at the content
-  // end off-screen when the user is not pinned to the floor.
-  const composerSeat = (
-    <div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
-      {composer}
-    </div>
-  )
-
-  return (
-    <div ref={rootResizeRef} className={css.root} data-phase={phase}>
-      {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
-      <div className={css.body}>
-        <div className={css.scrollBody} data-conversation-scroll="">
-          {sessionId === undefined ? null : renderSlot('conversation.session', {})}
-          {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}
-          />
-        ))}
-      </div>
-    </div>
-  )
+export function ConversationRoot(props: ConversationRootProps) {
+  return <ConversationMainPanel {...props} />
 }

+ 3 - 32
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -1,6 +1,5 @@
 /** Strict per-session header/body content inserted into the resident conversation layout. */
 
-import { useEffect } from 'react'
 import clsx from 'clsx'
 import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -9,6 +8,7 @@ import type {
 } from '../contract/slots.ts'
 import { conversationPhase } from '../contract/snapshot.ts'
 import { resolveActiveView } from '../view-selection.ts'
+import { DefaultConversationViews } from './DefaultConversationViews.tsx'
 import css from './ConversationRoot.module.css'
 
 /** Full props composed from the strict session body contract. */
@@ -165,35 +165,6 @@ export function ConversationSessionHeader({
  * @param props - Strict Session input/store, view ledger, and render shares.
  * @returns the active view area, or null while the Session remains blank.
  */
-export function ConversationSession({
-  useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
-  renderSlot, bindDraftMirror, openView,
-}: ConversationSessionProps) {
-  const tabs = useConversationViews(value => value)
-  const selectedId = useStore(s => s.view)
-  const active = resolveActiveView(tabs, selectedId)
-  const session = useSession(s => s)
-  const conversation = useConversation(s => s)
-  const inputState = useInput(s => s)
-  const storedDraft = useStore(s => s.draft)
-  const viewRequest = useStore(s => s.viewRequest ?? null)
-
-  useEffect(() => {
-    if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
-    const unmirror = bindDraftMirror(actions.setDraft)
-    return () => { unmirror() }
-    // Mount-only (deps pinned to inputActions): later store writes come from
-    // the machine mirror, not this seed effect.
-  }, [inputActions])
-
-  if (session.blank && conversationPhase(session, conversation) === 'blank') return null
-  return (
-    <div className={css.viewArea}>
-      {active !== undefined && renderSlot('conversation.view', {
-        viewRequest,
-        openView,
-        completeViewRequest: actions.completeViewRequest,
-      }, { only: active.id })}
-    </div>
-  )
+export function ConversationSession(props: ConversationSessionProps) {
+  return <DefaultConversationViews {...props} />
 }

+ 44 - 0
packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx

@@ -0,0 +1,44 @@
+import { useEffect } from 'react'
+import type { ConversationSessionSlotProps } from '../contract/slots.ts'
+import { conversationPhase } from '../contract/snapshot.ts'
+import { resolveActiveView } from '../view-selection.ts'
+import css from './ConversationRoot.module.css'
+
+/**
+ * Renders the active Session view inside the resident scrollport and keeps
+ * the input draft mirrored while blank Hero chrome is visible.
+ * @param props - Strict Session input/store, view ledger, and render shares.
+ * @returns the active view area, or null while the Session remains blank.
+ */
+export function DefaultConversationViews({
+  useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
+  renderSlot, bindDraftMirror, openView,
+}: ConversationSessionSlotProps) {
+  const tabs = useConversationViews(value => value)
+  const selectedId = useStore(s => s.view)
+  const active = resolveActiveView(tabs, selectedId)
+  const session = useSession(s => s)
+  const conversation = useConversation(s => s)
+  const inputState = useInput(s => s)
+  const storedDraft = useStore(s => s.draft)
+  const viewRequest = useStore(s => s.viewRequest ?? null)
+
+  useEffect(() => {
+    if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
+    const unmirror = bindDraftMirror(actions.setDraft)
+    return () => { unmirror() }
+    // Mount-only (deps pinned to inputActions): later store writes come from
+    // the machine mirror, not this seed effect.
+  }, [inputActions])
+
+  if (session.blank && conversationPhase(session, conversation) === 'blank') return null
+  return (
+    <div className={css.viewArea}>
+      {active !== undefined && renderSlot('conversation.view', {
+        viewRequest,
+        openView,
+        completeViewRequest: actions.completeViewRequest,
+      }, { only: active.id })}
+    </div>
+  )
+}

+ 2 - 2
packages/client/ui-workspace/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-workspace/README.md
-README.md: d504ad03e6153a987f94357bb939e75c592f7773
-README.zh.md: 10c4acd2dfa3c04f9e1d0e2027451907b0ab8fea
+README.md: 7317573add877fce9bdfdf359219053ae2452566
+README.zh.md: 1b803e3b91030171829d8a7f6f03270fa1aa566f

+ 3 - 3
packages/client/ui-workspace/README.md

@@ -29,7 +29,7 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
 
 ### Reordering and view options
 
-View options combine grouping with one browser-persisted Session order per account: **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once; entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Workspace drag order is Host-durable in either Session order mode.
+**Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. **Manual** freezes the current displayed order and holds positions when activity changes; newly discovered ordinary Sessions append to the end, newest first when several arrive together. Returning to Last updated discards every manual position, and entering Manual again freezes the then-current recency order. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging an ordinary Session applies the move locally and selects Manual. The selected blank **New Session** is always pinned first and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining its first position in Manual or following its current timestamp in Last updated. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable.
 
 ### Search
 
@@ -67,7 +67,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
 
 ### View state
 
-Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. Real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
+Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
 
 ### Hover cards
 
@@ -85,7 +85,7 @@ These pages cover the sidebar host, the hero surface, and the picking backends.
 - [ui-sidebar](../ui-sidebar/README.md) — the sidebar shell hosting the `sidebar.workspaces` hole.
 - [ui-conversation](../ui-conversation/README.md) — the chat surface hosting the Session Intent hero's picker hole.
 - [directory-picker-native](../../host/directory-picker-native/README.md) — the OS-chooser backend filling the directory-flow hole.
-- [Workspace Controller](../../api/workspace-controller/README.md) — the Host mutations and framework-neutral Client projection that own workspaces and ordering.
+- [Workspace Controller](../../api/workspace-controller/README.md) — the Host mutations and framework-neutral Client projection that own Workspaces, membership, and Workspace group order.
 
 -----
 

+ 3 - 3
packages/client/ui-workspace/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 重排序与视图选项
 
-视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续用户提示词或 steering(中途引导)会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表的顺序始终只保存在浏览器本地。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
+**最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。**手动排序**会冻结当前显示顺序,并在活动变化时保持已有位置;新发现的普通 Session 追加到末尾,多条同时到达时按最近更新时间降序排列。返回最近更新会丢弃所有手动位置;再次进入手动排序时,则冻结当时的最近更新顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽普通 Session 会在浏览器本地应用移动并选中手动排序。当前选中的空白**新会话**始终固定在首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留首位,在最近更新中按当前时间戳排列。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。
 
 ### 搜索
 
@@ -67,7 +67,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
 
 ### 视图状态
 
-Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 与单列表记账。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 与跨 Workspace 单列表从最近更新时间顺序初始化。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
+Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
 
 ### 悬浮卡片
 
@@ -85,7 +85,7 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
 - [ui-sidebar](../ui-sidebar/README.zh.md)——承载 `sidebar.workspaces` 子 slot 的侧边栏外壳。
 - [ui-conversation](../ui-conversation/README.zh.md)——承载 Session Intent 主视觉区选择器子 slot 的聊天界面。
 - [directory-picker-native](../../host/directory-picker-native/README.zh.md)——填充目录流子 slot 的 OS 选择器后端。
-- [Workspace Controller](../../api/workspace-controller/README.zh.md)——负责 Workspace 与排序的 Host 变更和框架无关 Client 投影。
+- [Workspace Controller](../../api/workspace-controller/README.zh.md)——负责 Workspace、成员关系与 Workspace 分组顺序的 Host 变更和框架无关 Client 投影。
 
 -----
 

+ 0 - 6
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -134,12 +134,6 @@ export type WorkspaceBrowserInjected = {
    * session clears the selection into the New Session view state.
    */
   archiveSession: (sessionId: SessionId) => Promise<void>
-  /**
-   * Reorder a session inside its Workspace account (DOM-insertBefore
-   * semantics: omitted anchor appends to the end). The view refreshes from
-   * the Host response/changed frame; failures leave the order unchanged.
-   */
-  insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
   /** Adopt a picked host directory as a real Workspace before targeting a Session. */
   createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
 }

+ 0 - 3
packages/client/ui-workspace/src/client/index.ts

@@ -126,9 +126,6 @@ export function apply(ctx: Context): void {
       await workspaces.insertBefore(workspaceId, beforeWorkspaceId)
     },
     archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) },
-    insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
-      await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
-    },
     createWorkspace: input => workspaces.create(input),
     hooks: { directoryFlow: browserFlowSource, hostInfo },
   })

+ 6 - 5
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -371,7 +371,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
  * @param props.onFork - fork a session at its last completed turn.
  * @param props.onArchive - archive a session by id.
  * @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
- * @param props.drag - optional draggable-row wiring.
+ * @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
  * @param props.flat - omit the empty status slot in the hierarchy-free flat list.
  * @param props.t - the browser root's locale seat.
  * @returns the session row.
@@ -391,7 +391,7 @@ export function SessionNodeItem({
   onArchive: (id: SessionNode['id']) => void
   /** Scroll this row into view after search navigation, then acknowledge it. */
   onReveal?: (() => void) | undefined
-  /** Present only on draggable rows (workspace-group sessions outside search). */
+  /** Present on reorderable-list rows so every row can remain a drop target. */
   drag?: RowDragProps | undefined
   /** The row is rendered without a parent Workspace header. */
   flat?: boolean | undefined
@@ -403,6 +403,7 @@ export function SessionNodeItem({
   const statuses = sessionStatuses(node, t)
   const primaryStatus = statuses[0]
   const showStatus = primaryStatus.state !== 'done' || row.completed
+  const draggable = drag !== undefined && !row.blank
   const [menuOpen, setMenuOpen] = useState(false)
   const rowRef = useRef<HTMLDivElement>(null)
   useEffect(() => {
@@ -431,15 +432,15 @@ export function SessionNodeItem({
       role="treeitem"
       aria-selected={selected}
       onClick={() => { onOpen(node.id) }}
-      draggable={drag !== undefined}
-      onDragStart={drag === undefined
+      draggable={draggable}
+      onDragStart={drag === undefined || row.blank
         ? undefined
         : (e) => {
           e.dataTransfer.effectAllowed = 'move'
           e.dataTransfer.setData('text/plain', node.id)
           drag.start()
         }}
-      onDragEnd={drag?.end}
+      onDragEnd={drag === undefined || row.blank ? undefined : drag.end}
       onDragOver={drag === undefined
         ? undefined
         : (e) => {

+ 148 - 236
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -23,7 +23,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceBrowserProps } from '../contract/slots.ts'
 import type { SessionNode, SessionOrderBy } from '../tree.ts'
 import {
-  deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, UNGROUPED_KEY,
+  deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey,
+  pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
 } from '../tree.ts'
 import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
 import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
@@ -95,73 +96,6 @@ function useNativeDragAcceptance(active: boolean): void {
   }, [active])
 }
 
-/** Reconcile a stored view order with the Workspace's current session account. */
-function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
-  if (stored === undefined) return [...sessionIds]
-  const byId = new Map(sessionIds.map(id => [id as string, id]))
-  const ordered: SessionId[] = []
-  const included = new Set<string>()
-  for (const key of stored) {
-    const id = byId.get(key)
-    if (id === undefined || included.has(key)) continue
-    ordered.push(id)
-    included.add(key)
-  }
-  for (const id of sessionIds) {
-    if (included.has(id)) continue
-    ordered.push(id)
-  }
-  return ordered
-}
-
-/** Newest update first with stable Session identity as the tie-break. */
-function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number {
-  const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY
-  const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY
-  if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt
-  return a < b ? -1 : 1
-}
-
-/** Reconcile one editable order account and apply its activity-promotion policy. */
-function nextSessionOrderAccount({
-  sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency,
-}: {
-  sessionIds: readonly SessionId[]
-  previousOrder: readonly string[] | undefined
-  previousUpdatedAt: Readonly<Record<string, number>>
-  list: SessionListState
-  orderBy: SessionOrderBy
-  sortByRecency: boolean
-}): { order: SessionId[]; updatedAt: Record<string, number>; changed: boolean } {
-  let order = reconciledSessionOrder(sessionIds, previousOrder)
-  if (sortByRecency) {
-    order.sort((a, b) => compareSessionRecency(a, b, list.byId))
-  } else if (orderBy === 'updated') {
-    const promoted = sessionIds
-      .filter((id) => {
-        const session = list.byId[id]
-        return session !== undefined
-          && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
-      })
-      .sort((a, b) => compareSessionRecency(a, b, list.byId))
-    if (promoted.length > 0) {
-      const promotedIds = new Set(promoted)
-      order = [...promoted, ...order.filter(id => !promotedIds.has(id))]
-    }
-  }
-  const updatedAt: Record<string, number> = {}
-  for (const id of sessionIds) {
-    const session = list.byId[id]
-    if (session !== undefined) updatedAt[id] = session.updatedAt
-  }
-  const orderChanged = previousOrder === undefined
-    || order.length !== previousOrder.length
-    || order.some((id, index) => id !== previousOrder[index])
-  const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length
-    || Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp)
-  return { order, updatedAt, changed: orderChanged || timestampsChanged }
-}
-
 /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
 function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
   groupBy: 'workspace' | 'flat'
@@ -234,26 +168,25 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
 
 type SessionTreeProps = Pick<
   WorkspaceBrowserProps,
-  'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
-  | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' | 'usePanelInfo'
+  'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
+  | 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
 > & {
+  /** Always-mounted Session list snapshot. */
+  list: SessionListState
   /** Host account home for POSIX hover-path abbreviation. */
   home?: string | undefined
+  /** Workspaces in Host group order with browser-projected Session order. */
   workspaces: readonly WorkspaceView[]
+  /** Browser-projected order for Sessions outside every Workspace. */
+  ungroupedSessionIds: readonly SessionId[]
   /** Whether the current Workspace stream has a complete Host baseline. */
   workspaceReady: boolean
   /** Explicit persisted zero-or-five-session state by Workspace group. */
   groupExpansion: Readonly<Record<string, boolean>>
   /** Persist one Workspace group's zero-or-five-session state. */
   setGroupExpanded: (key: string, expanded: boolean) => void
-  /** Shared editable orders used by Workspace groups and the flat-list account. */
-  sessionOrderByAccount: Readonly<Record<string, readonly string[]>>
-  /** Last update timestamps observed for one-time recent-update promotions. */
-  sessionUpdatedAtByAccount: Readonly<Record<string, Readonly<Record<string, number>>>>
-  /** Replace one shared order and its observed timestamps. */
-  syncSessionOrderAccount: (accountKey: string, order: string[], updatedAt: Record<string, number>) => void
-  /** Apply a drag to one shared order. */
-  setSessionOrder: (accountKey: string, order: string[]) => void
+  /** Save a drag order and select Manual. */
+  setSessionOrder: (accountKey: string, order: readonly string[]) => void
   /** Registry-global archive set (hidden rows). */
   archivedSessionIds: readonly SessionNode['id'][]
   /** Open the browser-owned rename dialog for a real Workspace group. */
@@ -264,8 +197,6 @@ type SessionTreeProps = Pick<
   onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
   /** Archive a session (row menu action; the row disappears on the state echo). */
   onSessionArchive: (sessionId: SessionNode['id']) => void
-  /** Session order behavior: fixed after edits, or additionally promoted by user activity. */
-  orderBy: SessionOrderBy
   /** One Session chosen from search that must be exposed and scrolled into view. */
   revealSessionId?: SessionId | undefined
   /** Acknowledge that the chosen Session row has been revealed. */
@@ -274,16 +205,16 @@ type SessionTreeProps = Pick<
 
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
-  useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds,
+  list, useSessionPendingInteraction, startSession, open, forkSession, workspaces, ungroupedSessionIds,
+  archivedSessionIds,
   workspaceReady, usePanelInfo,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
-  insertWorkspaceBefore, insertSessionBefore, orderBy,
+  insertWorkspaceBefore,
   groupExpansion, setGroupExpanded,
-  sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
+  setSessionOrder, home, t,
   revealSessionId, onSessionRevealed,
 }: SessionTreeProps) {
   const panelActive = usePanelInfo(info => info.activePanelId !== null)
-  const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
   const current = panelActive ? undefined : list.current
   const revealGroup = revealSessionId === undefined || !workspaceReady
@@ -295,7 +226,6 @@ function SessionTree({
   const sessionDropCommitted = useRef(false)
   const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
   const workspaceDropCommitted = useRef(false)
-  const previousOrderBy = useRef(orderBy)
   const nativeDragActive = drag !== null || workspaceDrag !== null
   useNativeDragAcceptance(nativeDragActive)
   const currentGroup = current === undefined || !workspaceReady
@@ -309,56 +239,12 @@ function SessionTree({
     () => Object.entries(groupExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
     [groupExpansion],
   )
-  const ungroupedSessionIds = useMemo(() => {
-    const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
-    return list.ids.filter((id: SessionId) => list.byId[id] !== undefined && !accounted.has(id))
-  }, [list, workspaces])
-  useEffect(() => {
-    if (list.phase !== 'ready') return
-    const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
-    previousOrderBy.current = orderBy
-    const accounts = [
-      ...workspaces.map(workspace => ({
-        key: workspace.workspaceId as string,
-        sessionIds: workspace.sessionIds.filter(id => list.byId[id] !== undefined),
-      })),
-      { key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
-    ]
-    for (const { key, sessionIds } of accounts) {
-      const previousOrder = sessionOrderByAccount[key]
-      const previousUpdatedAt = sessionUpdatedAtByAccount[key] ?? {}
-      const next = nextSessionOrderAccount({
-        sessionIds,
-        previousOrder,
-        previousUpdatedAt,
-        list,
-        orderBy,
-        sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
-      })
-      if (next.changed) {
-        syncSessionOrderAccount(key, next.order.map(id => id as string), next.updatedAt)
-      }
-    }
-  }, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, ungroupedSessionIds, workspaces])
-  const orderedWorkspaces = useMemo(() => {
-    return workspaces.map((workspace) => {
-      const stored = sessionOrderByAccount[workspace.workspaceId as string]
-      const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
-      return { ...workspace, sessionIds }
-    })
-  }, [sessionOrderByAccount, workspaces])
-  const orderedUngroupedSessionIds = useMemo(
-    () => reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]),
-    [sessionOrderByAccount, ungroupedSessionIds],
-  )
   const groups = useMemo(
-    () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, pendingInteractions, {
+    () => deriveGroups(list, workspaces, archivedSessionIds, pendingInteractions, {
       expandedGroups,
-      ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined
-        ? {}
-        : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }),
+      ungroupedOrder: ungroupedSessionIds,
     }),
-    [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount],
+    [list, workspaces, archivedSessionIds, pendingInteractions, expandedGroups, ungroupedSessionIds],
   )
   useEffect(() => {
     if (revealGroup === undefined || groupExpansion[revealGroup] === true) return
@@ -390,16 +276,15 @@ function SessionTree({
     const visibleInsertAt = over.half === 'before' ? targetWithoutSourceIndex : targetWithoutSourceIndex + 1
     if (sourceIndex !== -1 && visibleInsertAt === sourceIndex) return
     const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY
-      ? orderedUngroupedSessionIds
-      : orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
-    if (accountSessionIds === undefined) return
+      ? ungroupedSessionIds
+      : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
+    if (accountSessionIds === undefined || !accountSessionIds.includes(activeDrag.sessionId)) return
     const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
     let anchor: SessionId | undefined
     if (sessionsExpanded) {
       anchor = over.half === 'before' ? over.id : renderedSessions[targetIndex + 1]?.id
     } else {
-      // A collapsed group may render the blank row after hidden ordinary rows.
-      // Place the source at the visible boundary before those hidden account members.
+      // Place the source at the visible boundary before hidden account members.
       const previousVisible = withoutSource[visibleInsertAt - 1]?.id
       if (previousVisible === undefined) {
         anchor = nextOrder[0]
@@ -419,11 +304,8 @@ function SessionTree({
       })
       if (!collapsedSessionRows(nextGroup).rows.some(node => node.id === activeDrag.sessionId)) return
     }
-    setSessionOrder(activeDrag.accountKey, nextOrder.map(id => id as string))
-    if (orderBy === 'updated' || activeDrag.accountKey === UNGROUPED_KEY) return
-    insertSessionBefore(activeDrag.accountKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
-      console.warn('session reorder rejected:', reason)
-    })
+    const currentBlank = group.sessions.find(node => node.blank)?.id
+    setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank))
   }
   const commitWorkspaceDrag = (
     activeDrag: WorkspaceDragState,
@@ -553,9 +435,10 @@ function SessionTree({
                 ? group.sessions
                 : collapsed.rows
               ).map((node) => {
-              // Session drag never leaves its group. Ungrouped writes only the
-              // browser-local account; real Workspaces may also write Host order.
+              // Session drag never leaves its browser-local account.
                 const sameGroupDrag = drag !== null && drag.accountKey === group.key
+                const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' =>
+                  node.blank ? 'after' : half
                 const dragProps = {
                   start: () => {
                     sessionDropCommitted.current = false
@@ -565,12 +448,14 @@ function SessionTree({
                   marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
                   hover: (half: 'before' | 'after') => {
                   /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
-                    setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
+                    setDrag(d => (d === null ? d : {
+                      ...d, over: { id: node.id, half: normalizeHalf(half) },
+                    }))
                   },
                   drop: (half: 'before' | 'after') => {
                   /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
                     if (drag === null) return
-                    commitSessionDrag(drag, { id: node.id, half })
+                    commitSessionDrag(drag, { id: node.id, half: normalizeHalf(half) })
                   },
                   end: () => {
                     if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
@@ -619,64 +504,31 @@ function SessionTree({
 
 /** The flat "In one list" body: every session is one draggable top-level row. */
 function FlatList({
-  useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
-  archivedSessionIds, usePanelInfo,
-  orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder,
+  list, sessionIds, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
+  usePanelInfo, setSessionOrder,
   revealSessionId, onSessionRevealed, t,
 }: Pick<
   SessionTreeProps,
-  | 'useSessions'
   | 'useSessionPendingInteraction'
   | 'open'
   | 'forkSession'
   | 'onSessionRename'
   | 'onSessionArchive'
-  | 'archivedSessionIds'
   | 'usePanelInfo'
-  | 'orderBy'
-  | 'sessionOrderByAccount'
-  | 'sessionUpdatedAtByAccount'
-  | 'syncSessionOrderAccount'
   | 'setSessionOrder'
   | 'revealSessionId'
   | 'onSessionRevealed'
   | 't'
->) {
+> & {
+  list: SessionListState
+  sessionIds: readonly SessionId[]
+}) {
   const panelActive = usePanelInfo(info => info.activePanelId !== null)
-  const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
-  const baseRows = useMemo(
-    () => deriveFlat(list, archivedSessionIds, pendingInteractions),
-    [list, archivedSessionIds, pendingInteractions],
+  const rows = useMemo(
+    () => deriveFlat(list, sessionIds, pendingInteractions),
+    [list, sessionIds, pendingInteractions],
   )
-  const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
-  const previousOrderBy = useRef(orderBy)
-  useEffect(() => {
-    if (list.phase !== 'ready') return
-    const previousOrder = sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]
-    const previousUpdatedAt = sessionUpdatedAtByAccount[FLAT_SESSION_ORDER_KEY] ?? {}
-    const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
-    previousOrderBy.current = orderBy
-    const next = nextSessionOrderAccount({
-      sessionIds,
-      previousOrder,
-      previousUpdatedAt,
-      list,
-      orderBy,
-      sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
-    })
-    if (next.changed) {
-      syncSessionOrderAccount(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
-    }
-  }, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, sessionIds, syncSessionOrderAccount])
-  const rows = useMemo(() => {
-    const byId = new Map(baseRows.map(row => [row.id, row]))
-    return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
-      .flatMap((id) => {
-        const row = byId.get(id)
-        return row === undefined ? [] : [row]
-      })
-  }, [baseRows, sessionOrderByAccount, sessionIds])
   const [drag, setDrag] = useState<DragState | null>(null)
   const dropCommitted = useRef(false)
   useNativeDragAcceptance(drag !== null)
@@ -694,7 +546,8 @@ function FlatList({
     const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
     const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
     nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
-    setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
+    const currentBlank = rows.find(node => node.blank)?.id
+    setSessionOrder(FLAT_SESSION_ORDER_KEY, pinCurrentBlank(nextOrder, currentBlank))
   }
   const now = Date.now()
   return (
@@ -705,6 +558,8 @@ function FlatList({
         )}
         {rows.map((node) => {
           const active = drag !== null
+          const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' =>
+            node.blank ? 'after' : half
           return (
             <SessionNodeItem
               key={node.id}
@@ -727,10 +582,12 @@ function FlatList({
                 active,
                 marker: active && drag.over?.id === node.id ? drag.over.half : null,
                 hover: (half) => {
-                  setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } })
+                  setDrag(current => current === null ? current : {
+                    ...current, over: { id: node.id, half: normalizeHalf(half) },
+                  })
                 },
                 drop: (half) => {
-                  if (drag !== null) commitDrag(drag, { id: node.id, half })
+                  if (drag !== null) commitDrag(drag, { id: node.id, half: normalizeHalf(half) })
                 },
                 end: () => {
                   if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over)
@@ -767,7 +624,7 @@ function SearchResults({
   resultLimit,
   usePanelInfo,
   t,
-}: Pick<SessionTreeProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't' | 'usePanelInfo'> & {
+}: Pick<WorkspaceBrowserProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't' | 'usePanelInfo'> & {
   workspaces: readonly WorkspaceView[]
   archivedSessionIds: readonly SessionNode['id'][]
   query: string
@@ -853,7 +710,6 @@ export function WorkspaceBrowser({
   deleteWorkspace,
   insertWorkspaceBefore,
   archiveSession,
-  insertSessionBefore,
   createWorkspace,
   searchSessions,
   searchResultLimit,
@@ -863,6 +719,8 @@ export function WorkspaceBrowser({
   t,
 }: WorkspaceBrowserProps) {
   const home = useHostInfo(info => info.home)
+  // Ordering remains live while the rail or search replaces the list body.
+  const list = useSessions(state => state)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
   const workspaceStreamState = useWorkspaces(state => state.state)
@@ -874,33 +732,54 @@ export function WorkspaceBrowser({
   const orderBy = useStore(s => s.orderBy)
   const groupExpansion = useStore(s => s.groupExpansion)
   const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
-  const sessionUpdatedAtByAccount = useStore(s => s.sessionUpdatedAtByAccount)
-  const currentBlankSessionId = useSessions((state) => {
-    const current = state.current
-    return current !== undefined && state.byId[current]?.blank === true ? current : undefined
-  })
-  const currentBlankAccount = currentBlankSessionId === undefined
-    || workspacePhase !== 'ready'
-    ? undefined
-    : owningGroupKey(workspaces, currentBlankSessionId)
-  const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined)
-  useEffect(() => {
-    if (currentBlankSessionId === undefined || currentBlankAccount === undefined) {
-      promotedBlank.current = undefined
-      return
-    }
-    const promoted = promotedBlank.current
-    if (promoted !== undefined && promoted.sessionId === currentBlankSessionId
-      && promoted.accountKey === currentBlankAccount) return
-    promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
-    for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
-      const previous = sessionOrderByAccount[accountKey] ?? []
-      actions.setSessionOrder(accountKey, [
-        currentBlankSessionId,
-        ...previous.filter(id => id !== currentBlankSessionId),
-      ])
+  const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
+  const currentBlank = list.current !== undefined && list.byId[list.current]?.blank === true
+    ? list.current
+    : undefined
+  const ungroupedMemberIds = useMemo(() => {
+    const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
+    return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
+  }, [list, workspaces])
+  const flatMemberIds = useMemo(
+    () => visibleSessionIds(list, archivedSessionIds),
+    [archivedSessionIds, list],
+  )
+  const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => {
+    const memberIds = workspace.sessionIds
+    const baseOrder = orderBy === 'updated'
+      ? orderByRecency(memberIds, list.byId)
+      : reconcileManualOrder(memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId)
+    return {
+      ...workspace,
+      sessionIds: pinCurrentBlank(
+        baseOrder,
+        currentBlank !== undefined && memberIds.includes(currentBlank) ? currentBlank : undefined,
+      ),
     }
-  }, [actions.setSessionOrder, currentBlankAccount, currentBlankSessionId, sessionOrderByAccount])
+  }), [currentBlank, list.byId, orderBy, sessionOrderByAccount, workspaces])
+  const orderedUngroupedSessionIds = useMemo(() => {
+    const baseOrder = orderBy === 'updated'
+      ? orderByRecency(ungroupedMemberIds, list.byId)
+      : reconcileManualOrder(ungroupedMemberIds, sessionOrderByAccount[UNGROUPED_KEY], list.byId)
+    return pinCurrentBlank(
+      baseOrder,
+      currentBlank !== undefined && ungroupedMemberIds.includes(currentBlank) ? currentBlank : undefined,
+    )
+  }, [currentBlank, list.byId, orderBy, sessionOrderByAccount, ungroupedMemberIds])
+  const orderedFlatSessionIds = useMemo(() => {
+    const baseOrder = orderBy === 'updated'
+      ? orderByRecency(flatMemberIds, list.byId)
+      : reconcileManualOrder(flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId)
+    return pinCurrentBlank(
+      baseOrder,
+      currentBlank !== undefined && flatMemberIds.includes(currentBlank) ? currentBlank : undefined,
+    )
+  }, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount])
+  const activeSessionOrders = useMemo<Readonly<Record<string, readonly string[]>>>(() => Object.fromEntries([
+    ...orderedWorkspaces.map(workspace => [workspace.workspaceId as string, workspace.sessionIds] as const),
+    [UNGROUPED_KEY, orderedUngroupedSessionIds] as const,
+    [FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const,
+  ]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces])
   useEffect(() => {
     if (workspacePhase !== 'ready') return
     actions.retainAccountKeys([
@@ -909,6 +788,46 @@ export function WorkspaceBrowser({
       ...workspaces.map(workspace => workspace.workspaceId as string),
     ])
   }, [actions.retainAccountKeys, workspacePhase, workspaces])
+  useEffect(() => {
+    if (list.phase !== 'ready' || workspaceReady || orderBy !== 'manual' || currentBlank === undefined) return
+    // A first prompt can end blank pinning before the Workspace baseline arrives.
+    // Preserve saved members until that baseline can establish departures.
+    const changed: Record<string, readonly string[]> = {}
+    for (const [key, ids] of Object.entries(activeSessionOrders)) {
+      if (key !== FLAT_SESSION_ORDER_KEY && workspacePhase !== 'ready') continue
+      const saved = sessionOrderByAccount[key] ?? []
+      if (ids[0] !== currentBlank || saved[0] === currentBlank) continue
+      changed[key] = [currentBlank, ...saved.filter(id => id !== currentBlank)]
+    }
+    if (Object.keys(changed).length > 0) actions.syncSessionOrders(changed)
+  }, [
+    actions.syncSessionOrders,
+    activeSessionOrders,
+    currentBlank,
+    list.phase,
+    orderBy,
+    sessionOrderByAccount,
+    workspacePhase,
+    workspaceReady,
+  ])
+  useEffect(() => {
+    if (list.phase !== 'ready' || !workspaceReady || orderBy !== 'manual') return
+    const changed = Object.fromEntries(Object.entries(activeSessionOrders).filter(([key, ids]) => {
+      const saved = sessionOrderByAccount[key]
+      return saved === undefined || saved.length !== ids.length || ids.some((id, index) => id !== saved[index])
+    }))
+    if (Object.keys(changed).length > 0) actions.syncSessionOrders(changed)
+  }, [
+    actions.syncSessionOrders,
+    activeSessionOrders,
+    list.phase,
+    orderBy,
+    sessionOrderByAccount,
+    workspaceReady,
+  ])
+  const saveSessionOrder = (accountKey: string, order: readonly string[]): void => {
+    actions.setSessionOrder(accountKey, order, activeSessionOrders)
+  }
   // The query outlives the tree and the input (both wide-only) so collapsing
   // does not silently drop an in-progress filter.
   const [query, setQuery] = useState('')
@@ -1191,7 +1110,7 @@ export function WorkspaceBrowser({
               groupBy={groupBy}
               orderBy={orderBy}
               onGroupPick={(mode) => { actions.setGroupBy(mode) }}
-              onOrderPick={(mode) => { actions.setOrderBy(mode) }}
+              onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }}
               t={t}
             />
           )}
@@ -1273,15 +1192,12 @@ export function WorkspaceBrowser({
             ? (
               <FlatList
                 usePanelInfo={usePanelInfo}
-                useSessions={useSessions} useSessionPendingInteraction={useSessionPendingInteraction}
+                list={list}
+                sessionIds={orderedFlatSessionIds}
+                useSessionPendingInteraction={useSessionPendingInteraction}
                 open={open} forkSession={forkSession}
                 onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
-                archivedSessionIds={archivedSessionIds}
-                orderBy={orderBy}
-                sessionOrderByAccount={sessionOrderByAccount}
-                sessionUpdatedAtByAccount={sessionUpdatedAtByAccount}
-                syncSessionOrderAccount={actions.syncSessionOrderAccount}
-                setSessionOrder={actions.setSessionOrder}
+                setSessionOrder={saveSessionOrder}
                 revealSessionId={revealSessionId}
                 onSessionRevealed={acknowledgeSessionReveal}
                 t={t}
@@ -1290,25 +1206,21 @@ export function WorkspaceBrowser({
             : (
               <SessionTree
                 usePanelInfo={usePanelInfo}
-                useSessions={useSessions}
+                list={list}
                 useSessionPendingInteraction={useSessionPendingInteraction}
                 onSessionRename={onSessionRename}
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}
-                workspaces={workspaces}
-                workspaceReady={workspacePhase === 'ready' && workspaceStreamState !== 'loading'}
+                workspaces={orderedWorkspaces}
+                ungroupedSessionIds={orderedUngroupedSessionIds}
+                workspaceReady={workspaceReady}
                 groupExpansion={groupExpansion}
                 setGroupExpanded={actions.setGroupExpanded}
-                sessionOrderByAccount={sessionOrderByAccount}
-                sessionUpdatedAtByAccount={sessionUpdatedAtByAccount}
-                syncSessionOrderAccount={actions.syncSessionOrderAccount}
-                setSessionOrder={actions.setSessionOrder}
+                setSessionOrder={saveSessionOrder}
                 archivedSessionIds={archivedSessionIds}
                 startSession={startSession}
                 open={open}
                 insertWorkspaceBefore={insertWorkspaceBefore}
-                insertSessionBefore={insertSessionBefore}
-                orderBy={orderBy}
                 revealSessionId={revealSessionId}
                 onSessionRevealed={acknowledgeSessionReveal}
                 home={home}

+ 34 - 19
packages/client/ui-workspace/src/client/stores.ts

@@ -12,7 +12,7 @@ export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
 
 /** Session-list grouping mode: workspace sections or one flat recency list. */
 export type SessionGroupBy = 'workspace' | 'flat'
-/** Session order: user-arranged only, or user-arranged plus activity promotion. */
+/** Session order: saved manual positions or current recency. */
 export type SessionOrderBy = 'manual' | 'updated'
 
 /** Workspace browser viewing state persisted across surface remounts and reloads. */
@@ -21,10 +21,8 @@ type WorkspaceViewState = {
   orderBy: SessionOrderBy
   /** Explicit zero-or-five-session state keyed by Workspace group identity. */
   groupExpansion: Record<string, boolean>
-  /** Shared editable order per Workspace group plus the browser-local flat-list account. */
+  /** Saved manual order per Workspace group plus the browser-local flat-list account. */
   sessionOrderByAccount: Record<string, string[]>
-  /** Last observed update timestamps per order account for one-time promotion events. */
-  sessionUpdatedAtByAccount: Record<string, Record<string, number>>
 }
 
 /**
@@ -33,16 +31,30 @@ type WorkspaceViewState = {
  */
 type WorkspaceViewActions = {
   setGroupBy: (draft: WorkspaceViewState, mode: SessionGroupBy) => void
-  setOrderBy: (draft: WorkspaceViewState, mode: SessionOrderBy) => void
+  setOrderBy: (
+    draft: WorkspaceViewState,
+    mode: SessionOrderBy,
+    initialOrders: Readonly<Record<string, readonly string[]>>,
+  ) => void
   setGroupExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
   retainAccountKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
-  syncSessionOrderAccount: (
+  syncSessionOrders: (
+    draft: WorkspaceViewState,
+    orders: Readonly<Record<string, readonly string[]>>,
+  ) => void
+  setSessionOrder: (
     draft: WorkspaceViewState,
     accountKey: string,
-    order: string[],
-    updatedAt: Record<string, number>,
+    order: readonly string[],
+    initialOrders: Readonly<Record<string, readonly string[]>>,
   ) => void
-  setSessionOrder: (draft: WorkspaceViewState, accountKey: string, order: string[]) => void
+}
+
+/** Copy read-only projections into the persisted mutable store representation. */
+function copySessionOrders(
+  orders: Readonly<Record<string, readonly string[]>>,
+): Record<string, string[]> {
+  return Object.fromEntries(Object.entries(orders).map(([key, order]) => [key, [...order]]))
 }
 
 /**
@@ -56,12 +68,15 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
       orderBy: 'updated',
       groupExpansion: {},
       sessionOrderByAccount: {},
-      sessionUpdatedAtByAccount: {},
     }),
     persist: 'dsh.workspace.view.v5',
     actions: {
       setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
-      setOrderBy: (d, mode: SessionOrderBy) => { d.orderBy = mode },
+      setOrderBy: (d, mode: SessionOrderBy, initialOrders) => {
+        if (mode === d.orderBy) return
+        d.sessionOrderByAccount = mode === 'manual' ? copySessionOrders(initialOrders) : {}
+        d.orderBy = mode
+      },
       setGroupExpanded: (d, key: string, expanded: boolean) => { d.groupExpansion[key] = expanded },
       retainAccountKeys: (d, workspaceKeys: readonly string[]) => {
         const retained = new Set(workspaceKeys)
@@ -71,16 +86,16 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
         d.sessionOrderByAccount = Object.fromEntries(
           Object.entries(d.sessionOrderByAccount).filter(([key]) => retained.has(key)),
         )
-        d.sessionUpdatedAtByAccount = Object.fromEntries(
-          Object.entries(d.sessionUpdatedAtByAccount).filter(([key]) => retained.has(key)),
-        )
+        delete (d as WorkspaceViewState & { sessionUpdatedAtByAccount?: unknown }).sessionUpdatedAtByAccount
       },
-      syncSessionOrderAccount: (d, accountKey: string, order: string[], updatedAt: Record<string, number>) => {
-        d.sessionOrderByAccount[accountKey] = order
-        d.sessionUpdatedAtByAccount[accountKey] = updatedAt
+      syncSessionOrders: (d, orders) => {
+        if (d.orderBy !== 'manual') return
+        Object.assign(d.sessionOrderByAccount, copySessionOrders(orders))
       },
-      setSessionOrder: (d, accountKey: string, order: string[]) => {
-        d.sessionOrderByAccount[accountKey] = order
+      setSessionOrder: (d, accountKey, order, initialOrders) => {
+        if (d.orderBy === 'updated') d.sessionOrderByAccount = copySessionOrders(initialOrders)
+        d.orderBy = 'manual'
+        d.sessionOrderByAccount[accountKey] = [...order]
       },
     },
   })

+ 112 - 52
packages/client/ui-workspace/src/client/tree.ts

@@ -1,7 +1,7 @@
 /**
- * Derives the workspace browser tree from Host Workspace order and membership.
- * Unassigned Sessions trail under Ungrouped; only the selected blank Session
- * remains visible.
+ * Derives the workspace browser tree from caller-projected Workspace and
+ * Session order. Unassigned Sessions trail under Ungrouped; only the selected
+ * blank Session remains visible.
  */
 import {
   type SessionListState, type SessionSearchResultItem, type SessionSummary,
@@ -130,10 +130,68 @@ export function workspaceLabel(cwd: string | undefined): string {
   return base !== '' ? base : cwd
 }
 
-/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
-function byRecency(a: SessionSummary, b: SessionSummary): number {
-  if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
-  return a.id < b.id ? -1 : 1
+/**
+ * Project known account members by current Session recency.
+ * @param sessionIds - authoritative account membership.
+ * @param summaries - current Session summaries; members without a summary are omitted until it arrives.
+ * @returns known members newest first, with Session identity as the deterministic tie-break.
+ */
+export function orderByRecency(
+  sessionIds: readonly SessionId[],
+  summaries: SessionListState['byId'],
+): SessionId[] {
+  return sessionIds.flatMap((id) => {
+    const summary = summaries[id]
+    return summary === undefined ? [] : [{ id, updatedAt: summary.updatedAt }]
+  })
+    .sort((a, b) => {
+      if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt
+      return a.id < b.id ? -1 : 1
+    })
+    .map(member => member.id)
+}
+
+/**
+ * Reconcile a browser-local manual order with current account membership.
+ * @param memberIds - authoritative account membership.
+ * @param savedOrder - previously saved browser-local order.
+ * @param summaries - current Session summaries used to append newly known members by recency.
+ * @returns retained saved slots followed by newly known members; departed members and unknown new members are omitted.
+ */
+export function reconcileManualOrder(
+  memberIds: readonly SessionId[],
+  savedOrder: readonly string[] | undefined,
+  summaries: SessionListState['byId'],
+): SessionId[] {
+  const members = new Map(memberIds.map(id => [id as string, id]))
+  const included = new Set<string>()
+  const ordered: SessionId[] = []
+  for (const key of savedOrder ?? []) {
+    const id = members.get(key)
+    if (id === undefined || included.has(key)) continue
+    ordered.push(id)
+    included.add(key)
+  }
+  for (const id of orderByRecency(memberIds, summaries)) {
+    if (included.has(id)) continue
+    ordered.push(id)
+    included.add(id)
+  }
+  return ordered
+}
+
+/**
+ * Keep the selected provisional New Session ahead of either base order.
+ * @param order - recency or reconciled manual order.
+ * @param currentBlank - selected blank Session in this account, when present.
+ * @returns a copy with the selected blank first and no duplicate slot.
+ */
+export function pinCurrentBlank(
+  order: readonly SessionId[],
+  currentBlank: SessionId | undefined,
+): SessionId[] {
+  if (currentBlank === undefined) return [...order]
+  return [currentBlank, ...order.filter(id => id !== currentBlank)]
 }
 
 /**
@@ -170,38 +228,32 @@ function buildGroup(
   createdAt: number | undefined,
   label: string,
   members: readonly SessionSummary[],
-  order: 'account' | 'recency',
 ): Group {
-  const sessions = [...members]
-  // Real Workspace order comes from sessionIds. Ungrouped falls back to
-  // recency until the browser supplies its persisted local order.
-  if (order === 'recency') sessions.sort(byRecency)
-  return { key, workspaceId, cwd, createdAt, label, sessions }
+  return { key, workspaceId, cwd, createdAt, label, sessions: [...members] }
 }
 
 /** Apply a stored Ungrouped order and append newly loose Sessions by recency. */
-function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] {
+function orderedUngrouped(
+  members: readonly SessionSummary[],
+  stored: readonly string[] | undefined,
+  summaries: SessionListState['byId'],
+): SessionSummary[] {
   const byId = new Map(members.map(session => [session.id as string, session]))
-  const included = new Set<string>()
-  const ordered: SessionSummary[] = []
-  for (const key of stored) {
-    const session = byId.get(key)
-    if (session === undefined || included.has(key)) continue
-    ordered.push(session)
-    included.add(key)
-  }
-  for (const session of [...members].sort(byRecency)) {
-    if (included.has(session.id)) continue
-    ordered.push(session)
-  }
-  return ordered
+  const ids = stored === undefined
+    ? orderByRecency(members.map(session => session.id), summaries)
+    : reconcileManualOrder(members.map(session => session.id), stored, summaries)
+  return ids.flatMap((id) => {
+    const session = byId.get(id)
+    /* v8 ignore next -- ids are projected exclusively from the members used to build byId. */
+    return session === undefined ? [] : [session]
+  })
 }
 
 /**
- * Group Sessions by Host Workspace: one group per entity in stable Host
- * order, with members resolved from sessionIds in their stored order. Sessions
- * outside every Workspace trail in the browser-local Ungrouped order, which
- * falls back to recency before that order is initialized.
+ * Group Sessions by Workspace: one group per caller-ordered entity, with
+ * members resolved from caller-ordered sessionIds. Sessions outside every
+ * Workspace trail in the browser-local Ungrouped order, which falls back to
+ * recency before that order is initialized.
  */
 function groupByWorkspace(
   list: SessionListState,
@@ -222,7 +274,7 @@ function groupByWorkspace(
     }
     groups.push(buildGroup(
       workspace.workspaceId, workspace.workspaceId, workspace.path,
-      Date.parse(workspace.createdAt), workspace.title, members, 'account',
+      Date.parse(workspace.createdAt), workspace.title, members,
     ))
   }
   const stray = list.ids
@@ -236,8 +288,7 @@ function groupByWorkspace(
       undefined,
       undefined,
       '',
-      ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder),
-      ungroupedOrder === undefined ? 'recency' : 'account',
+      orderedUngrouped(stray, ungroupedOrder, list.byId),
     ))
   }
   return groups
@@ -283,7 +334,7 @@ function sessionNode(
  * Content search lives outside this derivation
  * (see {@link deriveSearchResults}).
  * @param list - sessions list snapshot (`current` feeds containsCurrent).
- * @param workspaces - real workspaces in stable Host order.
+ * @param workspaces - real Workspaces in Host group order with caller-projected Session order.
  * @param archivedSessionIds - registry-global archive set.
  * @param pendingInteractions - pending UI interactions by Session.
  * @param view - local expansion arrays.
@@ -323,30 +374,37 @@ export function deriveGroups(
 }
 
 /**
- * Derive the flat session list ("In one list" mode): every session — fork
- * children included — as a top-level row, strictly newest-first. No grouping,
- * no parent/child adjacency. Content search lives outside this derivation
- * (see {@link deriveSearchResults}).
+ * Select flat-list members without deriving row presentation or ordering.
  * @param list - sessions list snapshot.
  * @param archivedSessionIds - registry-global archive set.
+ * @returns known visible Session ids in list order, including ordinary forks and only the current blank.
+ */
+export function visibleSessionIds(
+  list: SessionListState,
+  archivedSessionIds: readonly SessionId[],
+): SessionId[] {
+  const archived = new Set(archivedSessionIds)
+  return list.ids.filter((id) => {
+    const s = list.byId[id]
+    return s !== undefined && sessionVisible(s, list.current, archived)
+  })
+}
+
+/**
+ * Derive flat rows from the browser's ordered visible Session ids.
+ * @param list - sessions list snapshot used to select the ids.
+ * @param sessionIds - known visible members in render order, including any pinned blank.
  * @param pendingInteractions - pending UI interactions by Session.
- * @returns flat rows in render order.
+ * @returns flat rows in the supplied order with current status indicators.
  */
 export function deriveFlat(
   list: SessionListState,
-  archivedSessionIds: readonly SessionId[],
+  sessionIds: readonly SessionId[],
   pendingInteractions: SessionPendingInteractions,
 ): SessionNode[] {
-  const archived = new Set(archivedSessionIds)
   const descendants = indexSubagentDescendants(list.byId)
-  const rows: SessionSummary[] = []
-  for (const id of list.ids) {
-    const s = list.byId[id]
-    if (s === undefined || !sessionVisible(s, list.current, archived)) continue
-    rows.push(s)
-  }
-  rows.sort(byRecency)
-  return rows.map(session => sessionNode(session, descendants, pendingInteractions))
+  return sessionIds
+    .map(id => sessionNode(list.byId[id] as SessionSummary, descendants, pendingInteractions))
 }
 
 /**
@@ -402,7 +460,9 @@ export function deriveSearchResults(
       local.push(summary)
     }
   }
-  local.sort(byRecency)
+  const localById = new Map(local.map(summary => [summary.id, summary]))
+  const orderedLocal = orderByRecency(local.map(summary => summary.id), list.byId)
+    .map(id => localById.get(id) as SessionSummary)
 
   const ordered: SessionSummary[] = []
   const included = new Set<SessionId>()
@@ -411,7 +471,7 @@ export function deriveSearchResults(
     included.add(summary.id)
     ordered.push(summary)
   }
-  for (const summary of local) include(summary)
+  for (const summary of orderedLocal) include(summary)
   for (const item of content.items) {
     const summary = list.byId[item.sessionId]
     if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)

+ 2 - 5
packages/client/ui-workspace/tests/apply.client.spec.ts

@@ -18,7 +18,6 @@ async function bench() {
     title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
   }))
   const rename = vi.fn(async () => ({}))
-  const insertSessionBefore = vi.fn(async () => ({}))
   const open = vi.fn()
   const clear = vi.fn()
   const selectPanel = vi.fn()
@@ -43,7 +42,7 @@ async function bench() {
     delete: vi.fn(async () => undefined),
     insertBefore: vi.fn(async () => undefined),
     archiveSession: vi.fn(async () => undefined),
-    insertSessionBefore,
+    insertSessionBefore: vi.fn(async () => ({})),
   } as never)
   ctx.provide('sessions', {
     list: {
@@ -73,7 +72,7 @@ async function bench() {
   ctx.provide('locale', locale)
   return {
     ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
-    insertSessionBefore, open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory,
+    open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory,
   }
 }
 
@@ -145,8 +144,6 @@ describe('ui-workspace apply', () => {
     expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
     await browser.renameWorkspace('ws' as never, 'renamed')
     expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
-    await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
-    expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
     await browser.createWorkspace({ path: '/tmp/browser-project' })
     expect(b.create).toHaveBeenCalledWith({ path: '/tmp/browser-project' })
 

+ 83 - 31
packages/client/ui-workspace/tests/tree.client.spec.ts

@@ -5,8 +5,8 @@ import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-s
 import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
-  deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, workspaceLabel,
-  UNGROUPED_KEY,
+  deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey,
+  pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY,
 } from '../src/client/tree.ts'
 import { createWorkspaceViewStore } from '../src/client/stores.ts'
 
@@ -48,8 +48,36 @@ describe('owningGroupKey', () => {
   })
 })
 
+describe('Session ordering', () => {
+  it('orders known members by recency with a stable identity tie-break', () => {
+    const summaries = list(summary('tie-b', 20), summary('older', 10), summary('tie-a', 20)).byId
+    expect(orderByRecency(
+      [sid('unknown'), sid('tie-b'), sid('older'), sid('tie-a')],
+      summaries,
+    )).toEqual([sid('tie-a'), sid('tie-b'), sid('older')])
+  })
+
+  it('reconciles retained manual slots and appends newly known members by recency', () => {
+    const summaries = list(summary('kept', 1), summary('newer', 30), summary('older', 20)).byId
+    expect(reconcileManualOrder(
+      [sid('saved-without-summary'), sid('kept'), sid('newer'), sid('older'), sid('new-without-summary')],
+      ['departed', 'saved-without-summary', 'kept'],
+      summaries,
+    )).toEqual([
+      sid('saved-without-summary'), sid('kept'), sid('newer'), sid('older'),
+    ])
+  })
+
+  it('pins only the selected blank without changing the base order', () => {
+    expect(pinCurrentBlank([sid('newer'), sid('blank'), sid('older')], sid('blank')))
+      .toEqual([sid('blank'), sid('newer'), sid('older')])
+    expect(pinCurrentBlank([sid('newer'), sid('older')], undefined))
+      .toEqual([sid('newer'), sid('older')])
+  })
+})
+
 describe('deriveGroups', () => {
-  it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
+  it('keeps caller-supplied Workspace and sessionIds order', () => {
     const sessions = list(summary('newer', 20), summary('older', 10))
     const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
     const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first']))
@@ -68,7 +96,7 @@ describe('deriveGroups', () => {
       sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
     )
     expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
-    expect(deriveFlat(sessions, noArchive, attention)[0])
+    expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), attention)[0])
       .toMatchObject({ pendingInteraction: 'plan-review', running: true })
   })
 
@@ -81,7 +109,7 @@ describe('deriveGroups', () => {
         { key: `${kind}:1`, kind, sessionId: awaiting.id },
       ]])
 
-      expect(deriveFlat(list(awaiting), noArchive, attention)[0]?.pendingInteraction).toBe(kind)
+      expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind)
     },
   )
 
@@ -147,7 +175,8 @@ describe('deriveGroups', () => {
     const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
     expect(doneNode.completed).toBe(true)
     expect(plainNode.completed).toBe(false)
-    expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true)
+    expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
+      .find(node => node.id === done.id)!.completed).toBe(true)
     const search = deriveSearchResults(
       sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
       noAttention, { items: [], hasMore: false }, 10,
@@ -178,7 +207,7 @@ describe('deriveGroups', () => {
     expect(deriveGroups(
       sessions, workspaces, noArchive, noAttention, view(['project']),
     )[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
-    expect(deriveFlat(sessions, noArchive, noAttention)
+    expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
       .map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
     expect(deriveSearchResults(
       sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10,
@@ -210,8 +239,9 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessionCount).toBe(2)
     expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
     expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
-    expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([
-      [fork.id, 1], [parent.id, 2],
+    expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
+      .map(node => [node.id, node.runningSubagentCount])).toEqual([
+      [parent.id, 2], [fork.id, 1],
     ])
     expect(deriveSearchResults(
       sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
@@ -294,40 +324,36 @@ describe('deriveGroups', () => {
 })
 
 describe('deriveFlat', () => {
-  it('flattens every session — fork children included — newest-first with id tiebreak', () => {
+  it('renders ordinary forks in the supplied order regardless of timestamps', () => {
     const parent = summary('parent', 10)
     const child = { ...summary('child', 30), parentId: parent.id }
     const tieB = summary('tie-b', 20)
     const tieA = summary('tie-a', 20)
-    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention)
-    expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
+    const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noAttention)
+    expect(rows.map(row => row.id)).toEqual([parent.id, tieA.id, child.id, tieB.id])
   })
 
   it('hides subagent-origin rows but keeps ordinary forks', () => {
     const parent = summary('parent', 1)
     const fork = { ...summary('fork', 2), parentId: parent.id }
     const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
-    const rows = deriveFlat(
-      { ...list(parent, fork, subagent), current: subagent.id },
-      noArchive,
-      noAttention,
-    )
-    expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
+    const ids = visibleSessionIds({ ...list(parent, fork, subagent), current: subagent.id }, noArchive)
+    expect(ids).toEqual([parent.id, fork.id])
   })
 
   it('tolerates ids whose summary has not landed yet', () => {
     const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
-    expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')])
+    expect(visibleSessionIds(partial, noArchive)).toEqual([sid('present')])
   })
 
   it('shows only the current blank session and excludes blanks from search', () => {
     const currentBlank = { ...summary('current-blank', 9), blank: true }
     const staleBlank = { ...summary('stale-blank', 8), blank: true }
     const sessions = {
-      ...list(summary('real', 1), currentBlank, staleBlank),
+      ...list(currentBlank, summary('real', 1), staleBlank),
       current: currentBlank.id,
     }
-    const rows = deriveFlat(sessions, noArchive, noAttention)
+    const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
     expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
     expect(rows.map(row => row.title)).toEqual(['', 'real'])
     expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -336,7 +362,7 @@ describe('deriveFlat', () => {
   it('hides archived sessions in flat mode', () => {
     const kept = summary('kept', 1)
     const gone = summary('gone', 2)
-    expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id])
+    expect(visibleSessionIds(list(kept, gone), archived('gone'))).toEqual([kept.id])
   })
 })
 
@@ -490,38 +516,64 @@ describe('deriveSearchResults', () => {
 })
 
 describe('createWorkspaceViewStore', () => {
-  it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => {
+  it('stores view preferences and selects Manual when saving a drag order', () => {
     const store = createWorkspaceViewStore().create()
     expect(store.getSnapshot().groupBy).toBe('workspace')
     expect(store.getSnapshot().orderBy).toBe('updated')
     store.actions.setGroupBy('flat')
-    store.actions.setOrderBy('updated')
+    store.actions.setOrderBy('updated', {})
     store.actions.setGroupExpanded('alpha', true)
-    store.actions.syncSessionOrderAccount('alpha', ['two', 'one'], { one: 1, two: 2 })
-    store.actions.setSessionOrder('alpha', ['one', 'two'])
+    store.actions.setSessionOrder('alpha', ['one', 'two'], {})
     expect(store.getSnapshot().groupBy).toBe('flat')
     expect(store.getSnapshot()).toMatchObject({
-      orderBy: 'updated',
+      orderBy: 'manual',
       groupExpansion: { alpha: true },
       sessionOrderByAccount: { alpha: ['one', 'two'] },
-      sessionUpdatedAtByAccount: { alpha: { one: 1, two: 2 } },
     })
   })
 
+  it('retains positions when reselecting Manual and snapshots the supplied order on mode switches', () => {
+    const store = createWorkspaceViewStore().create()
+    store.actions.setSessionOrder('alpha', ['two', 'one'], {})
+    store.actions.setOrderBy('manual', {})
+    expect(store.getSnapshot()).toMatchObject({ orderBy: 'manual', sessionOrderByAccount: { alpha: ['two', 'one'] } })
+    store.actions.setOrderBy('updated', {})
+    expect(store.getSnapshot()).toMatchObject({ orderBy: 'updated', sessionOrderByAccount: {} })
+    store.actions.setOrderBy('manual', { alpha: ['one', 'two'] })
+    expect(store.getSnapshot().sessionOrderByAccount).toEqual({ alpha: ['one', 'two'] })
+  })
+
+  it('snapshots every account when a recency drag selects Manual', () => {
+    const store = createWorkspaceViewStore().create()
+    store.actions.setSessionOrder('alpha', ['two', 'one'], {
+      alpha: ['one', 'two'],
+      beta: ['three'],
+    })
+    expect(store.getSnapshot()).toMatchObject({
+      orderBy: 'manual',
+      sessionOrderByAccount: { alpha: ['two', 'one'], beta: ['three'] },
+    })
+  })
+
+  it('ignores reconciliation writes outside Manual', () => {
+    const store = createWorkspaceViewStore().create()
+    store.actions.syncSessionOrders({ alpha: ['one'] })
+    expect(store.getSnapshot().sessionOrderByAccount).toEqual({})
+  })
+
   it('removes view state outside the retained Workspace key set', () => {
     const store = createWorkspaceViewStore().create()
     store.actions.setGroupExpanded('', true)
     store.actions.setGroupExpanded('alpha', true)
     store.actions.setGroupExpanded('deleted', true)
-    store.actions.syncSessionOrderAccount('alpha', ['alpha-session'], { 'alpha-session': 2 })
-    store.actions.syncSessionOrderAccount('deleted', ['deleted-session'], { 'deleted-session': 1 })
+    store.actions.setSessionOrder('alpha', ['alpha-session'], {})
+    store.actions.setSessionOrder('deleted', ['deleted-session'], {})
 
     store.actions.retainAccountKeys(['', 'alpha'])
 
     const snapshot = store.getSnapshot()
     expect(snapshot.groupExpansion).toEqual({ '': true, alpha: true })
     expect(snapshot.sessionOrderByAccount).toEqual({ alpha: ['alpha-session'] })
-    expect(snapshot.sessionUpdatedAtByAccount).toEqual({ alpha: { 'alpha-session': 2 } })
   })
 })
 

+ 293 - 152
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -25,7 +25,7 @@ afterEach(cleanup)
 const scrollIntoView = vi.fn()
 beforeEach(() => {
   localStorage.clear()
-  createWorkspaceViewStore().create().actions.setOrderBy('manual')
+  createWorkspaceViewStore().create().actions.setOrderBy('manual', {})
   Element.prototype.scrollIntoView = scrollIntoView
   scrollIntoView.mockClear()
 })
@@ -94,7 +94,6 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     deleteWorkspace: vi.fn(async () => {}),
     archiveSession: vi.fn(async () => {}),
     insertWorkspaceBefore: vi.fn(async () => {}),
-    insertSessionBefore: vi.fn(async () => {}),
     createWorkspace: vi.fn(async () => workspace('created', [])),
     useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
     useHostInfo: selector => selector({ home: undefined, isLoopback: true }),
@@ -113,6 +112,108 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
 }
 
 describe('WorkspaceBrowser', () => {
+  it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps %s recency independent of arrival order and saved manual positions', (mode) => {
+    localStorage.clear()
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+    const account = mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
+    preferences.actions.setSessionOrder(account, ['older', 'newer'], {})
+    preferences.actions.setSessionOrder(UNGROUPED_KEY, ['older', 'newer'], {})
+    preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['older', 'newer'], {})
+    preferences.actions.setGroupExpanded(account, true)
+    preferences.actions.setGroupExpanded(UNGROUPED_KEY, true)
+    localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({ ...preferences.getSnapshot(), orderBy: 'updated' }))
+    const b = mount({
+      useSessions: hook(sessionState([summary('newer', 100)])),
+      useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace(account, ['older', 'newer'])])),
+    })
+    const names = () => screen.getAllByRole('treeitem')
+      .filter(row => row.getAttribute('aria-expanded') === null)
+      .map(row => row.textContent?.includes('newer') ? 'newer' : 'older')
+    expect(names()).toEqual(['newer'])
+    rerender(b, { useSessions: hook(sessionState([summary('older', 20), summary('newer', 100)])) })
+    expect(names()).toEqual(['newer', 'older'])
+    rerender(b, { useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])) })
+    expect(names()).toEqual(['newer', 'older'])
+    rerender(b, { useSessions: hook(sessionState([summary('older', 120), summary('newer', 100)])) })
+    expect(names()).toEqual(['older', 'newer'])
+    rerender(b, { useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])) })
+    expect(names()).toEqual(['newer', 'older'])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['older', 'newer'])
+    b.view.unmount()
+    const restored = mount({
+      useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])),
+      useWorkspaces: b.props.useWorkspaces,
+    })
+    expect(names()).toEqual(['newer', 'older'])
+    act(() => { restored.store.actions.setOrderBy('manual', {}) })
+    expect(names()).toEqual(['newer', 'older'])
+  })
+
+  it.each(['workspace', 'flat', 'ungrouped'] as const)('starts Manual from the current %s order and forgets discarded layouts', (mode) => {
+    localStorage.clear()
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+    const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
+    preferences.actions.setGroupExpanded(account, true)
+    const b = mount({
+      useSessions: hook(sessionState([summary('a', 30), summary('b', 20), summary('c', 10)])),
+      useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['c', 'a', 'b'])])),
+    })
+    const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
+      .map(row => row.querySelector('[class*="title"]')?.textContent)
+    const pick = (name: string) => {
+      fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+      fireEvent.click(screen.getByRole('menuitem', { name }))
+    }
+    expect(names()).toEqual(['a', 'b', 'c'])
+    pick('手动排序')
+    expect(names()).toEqual(['a', 'b', 'c'])
+    const source = screen.getByText('c').closest('[role="treeitem"]') as HTMLElement
+    const target = screen.getByText('a').closest('[role="treeitem"]') as HTMLElement
+    target.getBoundingClientRect = () => ({
+      top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
+    })
+    fireEvent.dragStart(source, { dataTransfer: dragData() })
+    fireDrag(target, 'drop', 102)
+    expect(names()).toEqual(['c', 'a', 'b'])
+    rerender(b, { useSessions: hook(sessionState([summary('a', 30), summary('b', 40), summary('c', 10)])) })
+    expect(names()).toEqual(['c', 'a', 'b'])
+    b.view.unmount()
+    const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
+    expect(names()).toEqual(['c', 'a', 'b'])
+    expect(restored.store.getSnapshot().orderBy).toBe('manual')
+    pick('最近更新')
+    expect(names()).toEqual(['b', 'a', 'c'])
+    pick('手动排序')
+    expect(names()).toEqual(['b', 'a', 'c'])
+  })
+
+  it.each(['workspace', 'flat'] as const)('keeps the provisional blank first and time ties stable in %s recency', (groupBy) => {
+    localStorage.clear()
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(groupBy)
+    preferences.actions.setGroupExpanded('alpha', true)
+    const b = mount({
+      useSessions: hook(sessionState([
+        summary('tie-b', 100), summary('blank', 1, { blank: true }), summary('tie-a', 100),
+      ], { current: sid('blank') })),
+      useWorkspaces: hook(workspaceState([workspace('alpha', ['missing', 'tie-b', 'blank', 'tie-a'])])),
+    })
+    const rows = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
+    expect(rows().map(row => row.textContent)).toEqual([
+      expect.stringContaining('新会话'), expect.stringContaining('tie-a'), expect.stringContaining('tie-b'),
+    ])
+    expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
+    rerender(b, { useSessions: hook(sessionState([
+      summary('tie-b', 100), summary('blank', 1), summary('tie-a', 100),
+    ], { current: sid('blank') })) })
+    expect(rows().map(row => row.textContent)).toEqual([
+      expect.stringContaining('tie-a'), expect.stringContaining('tie-b'), expect.stringContaining('blank'),
+    ])
+    expect(b.store.getSnapshot().orderBy).toBe('updated')
+  })
+
   it('moves focus into Workspace controls without selecting a Session while a main panel is active', () => {
     const panelInfo = { activePanelId: 'panel-a' as MainPanelId }
     const b = mount({
@@ -165,15 +266,111 @@ describe('WorkspaceBrowser', () => {
     const b = mount({ useWorkspaces: hook(pending) })
     act(() => {
       b.store.actions.setGroupExpanded('deleted', true)
-      b.store.actions.syncSessionOrderAccount('deleted', ['session'], { session: 1 })
+      b.store.actions.setSessionOrder('deleted', ['session'], {})
     })
     expect(b.store.getSnapshot().groupExpansion).toEqual({ deleted: true })
 
     rerender(b, { useWorkspaces: hook(workspaceState([])) })
     await waitFor(() => {
       expect(b.store.getSnapshot().groupExpansion).toEqual({})
-      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({ [UNGROUPED_KEY]: [] })
-      expect(b.store.getSnapshot().sessionUpdatedAtByAccount).toEqual({ [UNGROUPED_KEY]: {} })
+      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({
+        [UNGROUPED_KEY]: [],
+        [FLAT_SESSION_ORDER_KEY]: [],
+      })
+    })
+  })
+
+  it.each(['workspace', 'flat', 'ungrouped'] as const)('retains the new Session position through a %s Workspace reconnect', (mode) => {
+    const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
+    const old = summary('old', 10)
+    const absent = summary('absent', 5)
+    const blank = summary('blank', 1, { blank: true })
+    const groups = (ids: string[]) => workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)])
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+    preferences.actions.setGroupExpanded(account, true)
+    preferences.actions.setSessionOrder(account, ['old', 'absent'], {})
+    const b = mount({
+      useSessions: hook(sessionState([old, absent])),
+      useWorkspaces: hook(groups(['old', 'absent'])),
+    })
+    const names = () => screen.getAllByRole('treeitem')
+      .filter(row => row.getAttribute('aria-expanded') === null)
+      .map(row => row.textContent)
+    rerender(b, {
+      useSessions: hook(sessionState([old, blank], { current: blank.id })),
+      useWorkspaces: hook({ ...groups(['old', 'blank']), state: 'loading' }),
+    })
+    expect(names()).toEqual([expect.stringContaining('新会话'), expect.stringContaining('old')])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+    })
+    expect(names()).toEqual([expect.stringContaining('blank'), expect.stringContaining('old')])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, absent, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+      useWorkspaces: hook(groups(['old', 'absent', 'blank'])),
+    })
+    expect(names()).toEqual([
+      expect.stringContaining('blank'), expect.stringContaining('old'), expect.stringContaining('absent'),
+    ])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+    rerender(b, {
+      useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+      useWorkspaces: hook(groups(['old', 'blank'])),
+    })
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old'])
+  })
+
+  it('reconciles a late blank and its first prompt while the sidebar is collapsed', async () => {
+    const old = summary('old', 10)
+    const blank = summary('blank', 1, { blank: true })
+    const b = mount({
+      wide: false,
+      useSessions: hook(sessionState([old])),
+      useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank'])])),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old'])
+    })
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, blank], { current: blank.id })),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
+    })
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
+    })
+
+    rerender(b, { wide: true })
+    expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
+      expect.stringContaining('blank'),
+      expect.stringContaining('old'),
+    ])
+  })
+
+  it('drops the obsolete timestamp ledger from persisted viewing state', async () => {
+    localStorage.clear()
+    localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({
+      groupBy: 'workspace',
+      orderBy: 'manual',
+      groupExpansion: {},
+      sessionOrderByAccount: {},
+      sessionUpdatedAtByAccount: { alpha: { old: 10 } },
+    }))
+    mount()
+    await waitFor(() => {
+      const persisted = JSON.parse(localStorage.getItem('dsh.workspace.view.v5') ?? '{}') as Record<string, unknown>
+      expect(persisted).not.toHaveProperty('sessionUpdatedAtByAccount')
     })
   })
 
@@ -219,7 +416,6 @@ describe('WorkspaceBrowser', () => {
   })
 
   it('persists flat-list drag order locally and applies Last updated within that account', async () => {
-    const insertSessionBefore = vi.fn(async () => {})
     const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
     const workspaces = workspaceState([
       workspace('alpha', ['one']),
@@ -228,7 +424,6 @@ describe('WorkspaceBrowser', () => {
     const b = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaces),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
@@ -247,13 +442,14 @@ describe('WorkspaceBrowser', () => {
     fireDrag(three, 'drop', 180)
     expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
       .toEqual(['two', 'three', 'one'])
-    expect(insertSessionBefore).not.toHaveBeenCalled()
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
     await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
-        .toEqual(['one', 'two', 'three'])
+      expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
+        expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
+      ])
+      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
     })
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
@@ -336,55 +532,38 @@ describe('WorkspaceBrowser', () => {
     expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
   })
 
-  it('anchors collapsed drags before hidden rows so the source stays visible', async () => {
+  it('pins the blank while collapsed drags keep an ordinary source visible', async () => {
     const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index))
     const blank = summary('blank', 7, { blank: true })
-    const insertSessionBefore = vi.fn(async () => {})
     const b = mount({
       useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })),
       useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])),
-      insertSessionBefore,
     })
     await waitFor(() => {
       expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
         .toEqual(['blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'])
     })
 
-    fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' }))
     const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
-    const session6 = screen.getByText('session-6').closest('[role="treeitem"]') as HTMLElement
-    session6.getBoundingClientRect = () => ({
-      top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34,
-      x: 0, y: 200, toJSON: () => ({}),
-    })
+    expect(blankRow.draggable).toBe(false)
     fireEvent.dragStart(blankRow, { dataTransfer: dragData() })
-    fireDrag(session6, 'drop', 230)
     expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
-      .toEqual(['session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6', 'blank'])
+      .toEqual(['blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'])
 
-    insertSessionBefore.mockClear()
-    fireEvent.click(screen.getByRole('button', { name: '收起' }))
-    const collapsedBlank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
-    collapsedBlank.getBoundingClientRect = () => ({
+    blankRow.getBoundingClientRect = () => ({
       top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34,
       x: 0, y: 200, toJSON: () => ({}),
     })
     const session5 = screen.getByText('session-5').closest('[role="treeitem"]') as HTMLElement
     fireEvent.dragStart(session5, { dataTransfer: dragData() })
-    fireDrag(collapsedBlank, 'drop', 205)
-    expect(insertSessionBefore).not.toHaveBeenCalled()
-
-    const session4 = screen.getByText('session-4').closest('[role="treeitem"]') as HTMLElement
-    fireEvent.dragStart(session4, { dataTransfer: dragData() })
-    fireDrag(collapsedBlank, 'drop', 205)
+    fireDrag(blankRow, 'drop', 205)
     expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
-      .toEqual(['session-1', 'session-2', 'session-3', 'session-5', 'session-4', 'session-6', 'blank'])
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('session-4'), sid('session-6'))
-    expect(screen.getByText('session-4')).toBeTruthy()
+      .toEqual(['blank', 'session-5', 'session-1', 'session-2', 'session-3', 'session-4', 'session-6'])
+    expect(screen.getByText('session-5')).toBeTruthy()
     expect(screen.queryByText('session-6')).toBeNull()
   })
 
-  it('shares one editable order across modes and promotes only while Last updated is active', async () => {
+  it('discards manual positions on recency selection and switches to Manual on drag', async () => {
     const initial = sessionState([summary('one', 3), summary('two', 2)])
     const b = mount({
       useSessions: hook(initial),
@@ -404,37 +583,33 @@ describe('WorkspaceBrowser', () => {
       top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
     })
     fireEvent.dragStart(one, { dataTransfer: dragData() })
+    fireDrag(one, 'drop', 180)
+    expect(b.store.getSnapshot().orderBy).toBe('updated')
+    fireEvent.dragStart(one, { dataTransfer: dragData() })
     fireDrag(two, 'drop', 180)
+    expect(b.store.getSnapshot().orderBy).toBe('manual')
     expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
     expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
 
-    // User activity updates the timestamp baseline in Manual mode without
-    // changing the shared visual order.
     const updated = sessionState([summary('one', 4), summary('two', 2)])
     rerender(b, { useSessions: hook(updated) })
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionUpdatedAtByAccount.alpha).toEqual({ one: 4, two: 2 })
-    })
     expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
     expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
 
-    // Entering Last updated performs one complete recency sort.
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
     await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
+      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
       expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
     })
 
-    // A later user activity timestamp promotes that Session once while the
-    // mode remains active.
     const promoted = sessionState([summary('one', 4), summary('two', 5)])
     rerender(b, { useSessions: hook(promoted) })
     await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
+      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
       expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
     })
 
@@ -443,7 +618,7 @@ describe('WorkspaceBrowser', () => {
       useSessions: hook(promoted),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
     })
-    expect(restored.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
+    expect(restored.store.getSnapshot().sessionOrderByAccount).toEqual({})
     expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
   })
 
@@ -579,70 +754,70 @@ describe('WorkspaceBrowser', () => {
     expect(screen.queryByText('新会话')).toBeNull()
   })
 
-  it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
-    const items = [
-      summary('old', 100),
-      summary('blank', 150, { blank: true }),
-      summary('mid', 200),
-    ]
-    const startSession = vi.fn()
-    const b = mount({
-      useSessions: hook(sessionState(items)),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
-      startSession,
-    })
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
-    })
-    startSession.mockImplementation(() => {
-      rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
-    })
-    fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
-    expect(startSession).toHaveBeenCalledWith(wid('alpha'))
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
-      expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
-    })
-    b.store.actions.setGroupBy('flat')
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
-    })
-  })
-
-  it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
-    const insertSessionBefore = vi.fn(async () => {})
+  it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps a new blank first across %s mode switches and enables drag after the first prompt', (mode) => {
+    localStorage.clear()
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+    const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
+    preferences.actions.setGroupExpanded(account, true)
+    const groups = (ids: string[]) => hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)]))
     const b = mount({
+      useSessions: hook(sessionState([summary('old', 100), summary('mid', 200)])),
+      useWorkspaces: groups(['old', 'mid']),
+    })
+    const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
+      .map(row => row.querySelector('[class*="title"]')?.textContent)
+    const pick = (name: string) => {
+      fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+      fireEvent.click(screen.getByRole('menuitem', { name }))
+    }
+    pick('手动排序')
+    pick('最近更新')
+    rerender(b, {
       useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('blank', 150, { blank: true }),
-        summary('mid', 200),
+        summary('old', 100), summary('mid', 200), summary('blank', 1, { blank: true }),
       ], { current: sid('blank') })),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
-      insertSessionBefore,
-    })
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
+      useWorkspaces: groups(['old', 'mid', 'blank']),
     })
+    expect(names()).toEqual(['新会话', 'mid', 'old'])
+    expect(b.store.getSnapshot().orderBy).toBe('updated')
+    pick('手动排序')
+    expect(names()).toEqual(['新会话', 'mid', 'old'])
+    pick('最近更新')
     const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
-    const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
-    mid.getBoundingClientRect = () => ({
+    expect(blank.draggable).toBe(false)
+    const old = screen.getByText('old').closest('[role="treeitem"]') as HTMLElement
+    old.getBoundingClientRect = () => ({
       top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
     })
     fireEvent.dragStart(blank, { dataTransfer: dragData() })
-    fireDrag(mid, 'drop', 180)
-    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
-
-    rerender(b, {
+    fireDrag(old, 'drop', 180)
+    expect(names()).toEqual(['新会话', 'mid', 'old'])
+    b.view.unmount()
+    const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
+    expect(names()).toEqual(['新会话', 'mid', 'old'])
+    rerender(restored, { useSessions: hook(sessionState([
+      summary('old', 100), summary('mid', 200), summary('blank', 300),
+    ], { current: sid('blank') })) })
+    expect(names()).toEqual(['blank', 'mid', 'old'])
+    const ordinaryBlank = screen.getByText('blank').closest('[role="treeitem"]') as HTMLElement
+    expect(ordinaryBlank.draggable).toBe(true)
+    const restoredOld = screen.getByText('old').closest('[role="treeitem"]') as HTMLElement
+    restoredOld.getBoundingClientRect = () => old.getBoundingClientRect()
+    fireEvent.dragStart(ordinaryBlank, { dataTransfer: dragData() })
+    fireDrag(restoredOld, 'drop', 180)
+    expect(names()).toEqual(['mid', 'old', 'blank'])
+    rerender(restored, {
       useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('blank', 150),
-        summary('mid', 200),
-      ], { current: sid('blank') })),
-    })
-    await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
-    })
+        summary('old', 100), summary('mid', 200), summary('blank', 300), summary('new-blank', 1, { blank: true }),
+      ], { current: sid('new-blank') })),
+      useWorkspaces: groups(['old', 'mid', 'blank', 'new-blank']),
+    })
+    expect(names()).toEqual(['新会话', 'mid', 'old', 'blank'])
+    pick('最近更新')
+    expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
+    pick('手动排序')
+    expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
   })
 
   it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
@@ -1168,13 +1343,11 @@ describe('WorkspaceBrowser', () => {
     expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
   })
 
-  it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
-    const insertSessionBefore = vi.fn(async () => {})
+  it('persists a grouped drag locally and skips no-op drops', () => {
     const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
-    mount({
+    const b = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByText('alpha'))
     const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
@@ -1187,7 +1360,7 @@ describe('WorkspaceBrowser', () => {
     // Drop on the top half of "three": insert one before three.
     fireDrag(three, 'dragOver', 205)
     fireDrag(three, 'drop', 205)
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
 
     // Dropping right back onto its own position is a no-op — top half
     // (anchor = itself) and bottom half (anchor = the next root) alike.
@@ -1197,19 +1370,17 @@ describe('WorkspaceBrowser', () => {
     })
     fireDrag(one, 'dragOver', 105)
     fireDrag(one, 'drop', 105)
-    expect(insertSessionBefore).toHaveBeenCalledTimes(1)
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
     fireEvent.dragStart(one, { dataTransfer })
     fireDrag(one, 'drop', 130)
-    expect(insertSessionBefore).toHaveBeenCalledTimes(1)
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
   })
 
-  it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => {
-    const insertSessionBefore = vi.fn(async () => {})
+  it('persists Ungrouped drag order in both modes', async () => {
     const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
     const b = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaceState([])),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByText('未分组'))
 
@@ -1227,22 +1398,22 @@ describe('WorkspaceBrowser', () => {
     expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
     dragAfter('two', 'one')
     expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
-    expect(insertSessionBefore).not.toHaveBeenCalled()
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
     await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
+      expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
+        expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
+      ])
+      expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
     })
     dragAfter('one', 'three')
     expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
-    expect(insertSessionBefore).not.toHaveBeenCalled()
 
     b.view.unmount()
     const restored = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaceState([])),
-      insertSessionBefore,
     })
     expect(restored.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
     expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
@@ -1252,13 +1423,11 @@ describe('WorkspaceBrowser', () => {
     ])
   })
 
-  it('still sends the reorder when the dragged row left the group mid-drag', () => {
-    const insertSessionBefore = vi.fn(async () => {})
+  it('ignores a drag whose source left the group in flight', () => {
     const sessions = sessionState([summary('one', 2), summary('two', 1)])
     const b = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByText('alpha'))
     const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
@@ -1271,16 +1440,14 @@ describe('WorkspaceBrowser', () => {
       top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
     })
     fireDrag(two, 'drop', 155)
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two'])
   })
 
   it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
-    const insertSessionBefore = vi.fn(async () => {})
     const sessions = sessionState([summary('one', 2), summary('two', 1)])
-    mount({
+    const b = mount({
       useSessions: hook(sessions),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByText('alpha'))
     const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
@@ -1292,21 +1459,19 @@ describe('WorkspaceBrowser', () => {
     fireEvent.dragEnd(one)
     // The drag ended: rows no longer accept drops.
     fireDrag(two, 'drop', 180)
-    expect(insertSessionBefore).not.toHaveBeenCalled()
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
 
     // Bottom half of the last row: append (anchor omitted).
     fireEvent.dragStart(one, { dataTransfer })
     fireDrag(two, 'dragOver', 180)
     fireDrag(two, 'drop', 180)
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
   })
 
   it('accepts a document-level drop and commits the last Session marker on drag end', () => {
-    const insertSessionBefore = vi.fn(async () => {})
-    mount({
+    const b = mount({
       useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
-      insertSessionBefore,
     })
     fireEvent.click(screen.getByText('alpha'))
     const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
@@ -1320,31 +1485,7 @@ describe('WorkspaceBrowser', () => {
     fireEvent(document.body, outsideDrop)
     expect(outsideDrop.defaultPrevented).toBe(true)
     fireEvent.dragEnd(one)
-    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
-  })
-
-  it('logs and keeps the order when the reorder call rejects', async () => {
-    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
-    try {
-      const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
-      const sessions = sessionState([summary('one', 2), summary('two', 1)])
-      mount({
-        useSessions: hook(sessions),
-        useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
-        insertSessionBefore,
-      })
-      fireEvent.click(screen.getByText('alpha'))
-      const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
-      two.getBoundingClientRect = () => ({
-        top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
-      })
-      const dataTransfer = dragData()
-      fireEvent.dragStart(one, { dataTransfer })
-      fireDrag(two, 'drop', 180)
-      await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
-    } finally {
-      warn.mockRestore()
-    }
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
   })
 
   it('renames a workspace through the row menu dialog', async () => {

+ 2 - 2
packages/experimental/agent-team-profile/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/experimental/agent-team-profile/README.md
-README.md: 511b42269d68e508c797bbd9499c462a269bf552
-README.zh.md: 243fd9145982d05626c04cae758e7cb3c688ebd9
+README.md: 86ac08c5d2278013ecbc371301bbb6a3a01a65d9
+README.zh.md: 973f5f2342b32109d26291d762f4ad7b9b4c3306

+ 6 - 5
packages/experimental/agent-team-profile/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Published experimental Agent Teams profile layer over dsh-base with Team-scoped coordination tools and one-shot delegation."
+description: "Published experimental Agent Teams profile layer over dsh-base with teammate delegation and fresh workflow children."
 kind: "package-bundle"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Add it explicitly to an initialized profile; no shipped profile enables it by default.
+`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. Add it explicitly to an initialized profile; no shipped profile enables it by default.
 
 ## Table of Contents
 
@@ -38,7 +38,7 @@ The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent service
 
 ### What you get
 
-The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. It disables the global continuable-child control rows whose tool names overlap with Team controls, while leaving `subagent` and `subagent_fork` available as one-shot delegation tools.
+The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow.
 
 -----
 
@@ -48,7 +48,7 @@ The layer adds the Agent Teams domain and its scoped creation, roster, messaging
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-base`, the patch disables `tool-subagent-control` and `tool-subagent-list-agents`; sets the fresh and fork Subagent rows to `one-shot`; and inserts the Team service and tool rows with explicit providers and limits.
+The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-base`, the patch disables `tool-subagent-control`, `tool-subagent-list-agents`, `tool-subagent`, and `tool-subagent-fork`, and inserts the Team service and tool rows with explicit providers and limits.
 
 | File | Role |
 |---|---|
@@ -77,7 +77,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied
 
 #### What the model sees
 
-The Team policy and schemas belong to [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.md). This bundle changes composition only: Team-scoped `list_agents`, `send_message`, and `interrupt_agent` replace the disabled global continuable-child controls. `subagent` and `subagent_fork` remain available as one-shot delegation tools, whose children do not receive the continuable-child `report` tool.
+The Team policy and schemas belong to [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.md). This bundle changes composition only: Team-scoped `list_agents`, `send_message`, and `interrupt_agent` replace the disabled global continuable-child controls. `spawn_teammate` is the direct delegation tool. Workflow’s `agent()` calls create fresh one-shot children; their prompts must contain the context needed for their tasks.
 
 #### Token effect
 
@@ -92,6 +92,7 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co
 <a id="known-limitations-and-deferred-work"></a>
 
 - **Opt-in only** — the package is public, but no shipped CLI, Web, SDK, ACP, or Python profile enables it.
+- **Workflow child tools** — the [Team tool visibility limitation](../tool-agent-team/README.md#known-limitations-and-deferred-work) also applies to workflow children.
 - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking.
 - **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile.
 

+ 6 - 5
packages/experimental/agent-team-profile/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "叠加在 dsh-base 上公开发布的实验性 Agent Teams profile 层,提供 Team-scoped 协作工具并保留一次性 delegation。"
+description: "叠加在 dsh-base 上公开发布的实验性 Agent Teams profile 层,提供 teammate 委派与 fresh workflow 子代理。"
 kind: "package-bundle"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-bundle"
 
 ## 概述
 
-`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh 与 fork delegation 工具。必须将本包显式添加到已初始化的 profile;随附 profile 默认都不会启用它。
+`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。必须将本包显式添加到已初始化的 profile;随附 profile 默认都不会启用它。
 
 ## 目录
 
@@ -38,7 +38,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su
 
 ### 获得的功能
 
-本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。它会禁用工具名与 Team control 重叠的全局 continuable-child control 行,同时保留 `subagent` 与 `subagent_fork` 作为一次性 delegation 工具。
+本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。
 
 -----
 
@@ -48,7 +48,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su
 <details>
 <summary>实现细节——点击展开</summary>
 
-本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-base` 之后应用时,patch 会禁用 `tool-subagent-control` 与 `tool-subagent-list-agents`,把 fresh 与 fork Subagent 行设置为 `one-shot`,并以显式 provider 和限制插入 Team 服务与工具行。
+本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-base` 之后应用时,patch 会禁用 `tool-subagent-control`、`tool-subagent-list-agents`、`tool-subagent` 和 `tool-subagent-fork`,并以显式 provider 和限制插入 Team 服务与工具行。
 
 | 文件 | 职责 |
 |---|---|
@@ -77,7 +77,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su
 
 #### 模型会看到什么
 
-Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.zh.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents`、`send_message` 与 `interrupt_agent` 会替代已禁用的全局 continuable-child control。`subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用,其子 agent 不会获得 continuable-child `report` 工具。
+Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.zh.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents`、`send_message` 与 `interrupt_agent` 会替代已禁用的全局 continuable-child control。`spawn_teammate` 是直接委派工具。Workflow 的 `agent()` 调用创建 fresh 一次性子代理;其提示词必须包含任务所需的上下文。
 
 #### Token 影响
 
@@ -92,6 +92,7 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t
 <a id="known-limitations-and-deferred-work"></a>
 
 - **仅显式启用**——本包公开发布,但随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。
+- **Workflow 子代理工具**——[Team 工具可见性限制](../tool-agent-team/README.zh.md#known-limitations-and-deferred-work)也适用于 workflow 子代理。
 - **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。
 - **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent 提供方;它不是独立 profile。
 

+ 3 - 10
packages/experimental/agent-team-profile/cordis.patch.yml

@@ -1,6 +1,5 @@
 # Experimental Agent Teams profile layer. Apply after dsh-base so these replacements
-# remove the global continuable-child controls before the scoped Team tools
-# register the overlapping list_agents, send_message, and interrupt_agent names.
+# keep direct delegation and coordination on the Team tools.
 
 - id: tool-subagent-control
   disabled: true
@@ -9,16 +8,10 @@
   disabled: true
 
 - id: tool-subagent
-  config:
-    provider: spawn
-    toolName: subagent
-    backgroundMode: one-shot
+  disabled: true
 
 - id: tool-subagent-fork
-  config:
-    provider: fork
-    toolName: subagent_fork
-    backgroundMode: one-shot
+  disabled: true
 
 - insert:
     - id: agent-team

+ 2 - 2
packages/experimental/agent-team-profile/tests/profile.spec.ts

@@ -37,8 +37,8 @@ describe('Agent Teams profile bundle', () => {
     }[]
     expect(patches.find(patch => patch.id === 'tool-subagent-control')).toMatchObject({ disabled: true })
     expect(patches.find(patch => patch.id === 'tool-subagent-list-agents')).toMatchObject({ disabled: true })
-    expect(patches.find(patch => patch.id === 'tool-subagent')?.config).toMatchObject({ backgroundMode: 'one-shot' })
-    expect(patches.find(patch => patch.id === 'tool-subagent-fork')?.config).toMatchObject({ backgroundMode: 'one-shot' })
+    expect(patches.find(patch => patch.id === 'tool-subagent')).toMatchObject({ disabled: true })
+    expect(patches.find(patch => patch.id === 'tool-subagent-fork')).toMatchObject({ disabled: true })
     const inserted = patches.flatMap(patch => patch.insert ?? [])
     expect(inserted.find(entry => entry.id === 'agent-team')).toMatchObject({
       name: '@deepseek-ai/dsh-experimental-agent-team',

+ 2 - 2
packages/experimental/agent-team/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/experimental/agent-team/README.md
-README.md: 3aa5f8606c76111dc2a85ce505f7989b15cf3b78
-README.zh.md: edf69e9d7dc852d75ca276dba6cddd950929df81
+README.md: 1dba55aaef5c2f061de85de3b38cf5c644fa90b7
+README.zh.md: 749f5b9d27ed863639a6dabad80254c9a19727dd

+ 1 - 1
packages/experimental/agent-team/README.md

@@ -48,7 +48,7 @@ With the tools installed, the model does the rest on request — for example, "c
 
 | Field | Default | Meaning |
 |---|---|---|
-| `maxMembers` | `8` | Maximum teammates a team may ever create, including failed ones |
+| `maxMembers` | `16` | Maximum teammates a team may ever create, including failed ones |
 | `maxTasks` | `256` | Maximum active tasks on the board |
 | `maxPendingMessagesPerMember` | `64` | Maximum queued messages for one member |
 | `maxMessageBytes` | `65,536` | Maximum size of one sent message |

+ 1 - 1
packages/experimental/agent-team/README.zh.md

@@ -48,7 +48,7 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `maxMembers` | `8` | 一支团队最多可创建的 teammate 数,包括失败的 |
+| `maxMembers` | `16` | 一支团队最多可创建的 teammate 数,包括失败的 |
 | `maxTasks` | `256` | 任务板上最多的活动任务数 |
 | `maxPendingMessagesPerMember` | `64` | 单个成员最多可排队的消息数 |
 | `maxMessageBytes` | `65,536` | 单条发送消息的最大尺寸 |

+ 1 - 1
packages/experimental/agent-team/src/index.ts

@@ -41,7 +41,7 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
-const DEFAULT_MAX_MEMBERS = 8
+const DEFAULT_MAX_MEMBERS = 16
 const DEFAULT_MAX_TASKS = 256
 const DEFAULT_MAX_PENDING_MESSAGES = 64
 const DEFAULT_MAX_MESSAGE_BYTES = 65_536

+ 2 - 2
packages/experimental/tool-agent-team/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/experimental/tool-agent-team/README.md
-README.md: 58e2853a863710ec094f61b0af04a0fe9b6bd4ce
-README.zh.md: 5dd4d0fd100f7f962ad89b1e463776d7e0e3d8e0
+README.md: 225b9cb90173803ba7c777c5d643e04e47c6c602
+README.zh.md: 382c60ee0c92c26919d52ae24137913800fb5fa7

+ 7 - 6
packages/experimental/tool-agent-team/README.md

@@ -25,7 +25,7 @@ This package lets the model create named teammates, send them messages, inspect
 <a id="use-this-package"></a>
 ## Use this package
 
-Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same nine tools plus a policy paragraph that states its own role and name.
+Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same nine tools plus the same coordination policy. `spawn_teammate` prefixes the initial task with the teammate’s role and name.
 
 ### When to choose it
 
@@ -81,7 +81,7 @@ This section explains the design decisions behind the adapter and points at the
 
 The adapter is built on three commitments:
 
-- **Scoped, not global.** Every registration lives on the member Agent's own `ctx`; nothing is installed for non-Team subagents or the host.
+- **Scoped, not global.** Every registration lives on the member Agent's own `ctx`; installation uses the member identity available when the Agent is published.
 - **Declared results, compact JSON.** Every tool declares its complete result schema and renders that value as compact JSON, so the compiler checks `execute` against what the model is promised and no result spends tokens on indentation.
 - **The domain owns authority.** Tools delegate to `ctx.agentTeams`, which enforces Lead authority and revision checks; the adapter adds no weaker path.
 
@@ -96,7 +96,7 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-
 
 ### Policy and tools
 
-One `team:policy` section on the member scope teaches each member its role and the coordination rules; the fixed text and the nine tool registrations are declared in [`src/index.ts`](src/index.ts). The nine tool schemas appear only in Team member scopes, so non-Team subagents keep the default catalog. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only.
+One `team:policy` section on the member scope states the shared coordination rules; the fixed text and the nine tool registrations are declared in [`src/index.ts`](src/index.ts). The nine tool schemas are registered in scopes recognized as Team members at publication. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only.
 
 ### Scoped registration and teardown
 
@@ -125,20 +125,21 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-One stable policy section states the exact Team role/name/id, the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The nine Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes.
+One shared system policy states the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. All nine Team schemas are identical for Leads and teammates; execution enforces Lead-only operations. `spawn_teammate` prefixes its initial user message with `<system-reminder>\nYou are teammate "<name>".\n</system-reminder>`, followed by a blank line and the task. The prefix contains no Team id and works when runtime context is disabled. Forks inherit history without an additional Lead identity message.
 
 #### Token effect
 
-Fixed policy and schema cost on every Team member request. Tool calls add compact JSON roster, task, wait, or receipt results. Peer content is retained by the Team domain in the target's history.
+Fixed policy and schema cost on every Team member request. The initial identity text follows ordinary history through later steps, cold recovery, and compaction; the plugin neither scans for it nor reinserts it. Tool calls add compact JSON roster, task, wait, or receipt results. Peer content is retained by the Team domain in the target's history.
 
 #### KV Cache effect
 
-Prefix-stable while the Team plugin generation, configuration, member role/name, and schemas remain unchanged. The per-member identity line differs across Agents. Tool results and peer messages append after the reusable request prefix.
+With the same provider/model, shared system policy, and tool schemas, a fork retains the parent request prefix and appends the initial task with its identity prefix. Tool results and peer messages append after the reusable request prefix. Sessions recorded with identity inside the system prompt can change that prefix on their first request under this layout; actual provider cache hits remain best-effort.
 
 ## Known Limitations and Deferred Work
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- **One-shot child tool visibility** — in-process one-shot children receive their subagent descriptor after publication. Team installation can therefore mistake them for Leads and expose Team policy and tools. Calls are rejected once the descriptor identifies them as non-members. Correcting installation timing is deferred.
 
 These limits describe what the policy and tools cannot guarantee for a team. They are current package constraints, not a comparison with other collaboration surfaces.
 

+ 7 - 6
packages/experimental/tool-agent-team/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的九个工具,外加一段说明自身角色与名字的策略段落。
+当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的九个工具,以及相同的协作策略。`spawn_teammate` 在初始任务前加上 teammate 的角色和名字。
 
 ### 何时选择
 
@@ -81,7 +81,7 @@ kind: "package-reference"
 
 适配器建立在三项承诺之上:
 
-- **按作用域,而非全局。** 每个注册都位于成员 Agent(智能体)自己的 `ctx` 上;非 Team subagent 或宿主不会安装任何内容。
+- **按作用域,而非全局。** 每个注册都位于成员 Agent(智能体)自己的 `ctx` 上;安装依据 Agent 发布时可用的成员身份。
 - **声明式结果,紧凑 JSON。** 每个工具都声明完整结果 schema,并把该值渲染为紧凑 JSON,因此编译器会对照向模型承诺的结果检查 `execute`,任何结果都不会在缩进上消耗 token。
 - **领域掌握裁决权。** 工具委托给 `ctx.agentTeams`,后者强制执行 Lead 权限与 revision 校验;适配器不添加更弱的路径。
 
@@ -96,7 +96,7 @@ kind: "package-reference"
 
 ### 策略与工具
 
-member scope 上的一个 `team:policy` 段落教每个成员自己的角色与协作规则;固定文本与九个工具注册都声明在 [`src/index.ts`](src/index.ts)。九个工具 schema 只出现在 Team member scope 中,因此非 Team subagent 保持默认目录。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。
+member scope 上的一个 `team:policy` 段落说明共享的协作规则;固定文本与九个工具注册都声明在 [`src/index.ts`](src/index.ts)。九个工具 schema 注册在发布时被识别为 Team member 的 scope 中。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。
 
 ### 按作用域注册与拆除
 
@@ -125,20 +125,21 @@ member scope 上的一个 `team:policy` 段落教每个成员自己的角色与
 
 #### 模型看到什么
 
-一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件陈旧版本恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的九个 Team schema 只出现在 Team member scope。
+一段共享 system 策略会说明显式 delegation 要求、共享 cwd 行为、文件陈旧版本恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。Lead 与 teammate 的全部九个 Team schema 相同;执行时检查仅限 Lead 的操作权限。`spawn_teammate` 在初始 user 消息前加上 `<system-reminder>\nYou are teammate "<name>".\n</system-reminder>`,接着是一个空行和任务。该前缀不含 Team id,禁用运行时上下文时也能生效。fork 继承历史,不额外添加 Lead 身份消息。
 
 #### Token 影响
 
-每次 Team member 请求都有固定策略与 schema 成本。工具调用会增加紧凑 JSON roster、task、wait 或 receipt 结果。Peer 内容由 Team 领域保留在 target 历史中。
+每次 Team member 请求都有固定策略与 schema 成本。初始身份文本随普通历史经历后续步骤、冷恢复和压缩;插件不扫描或重新插入它。工具调用会增加紧凑 JSON roster、task、wait 或 receipt 结果。Peer 内容由 Team 领域保留在 target 历史中。
 
 #### KV Cache 影响
 
-Team 插件 generation、配置、member role/name 与 schema 不变时,前缀保持稳定。每个成员的身份行不同。工具结果与 peer 消息追加在可复用请求前缀之后。
+provider/model、共享 system 策略和工具 schema 相同时,fork 保留父请求前缀并追加带身份前缀的初始任务。工具结果与 peer 消息追加在可复用请求前缀之后。原先在 system prompt 中记录身份的 Session,首次使用此布局请求时可能改变该前缀;提供方实际缓存命中仍为尽力而为。
 
 ## 已知限制与延期工作
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- **一次性子代理工具可见性**——进程内一次性子代理在发布后才获得 subagent descriptor。Team 安装因此可能将它们误认作 Lead 并暴露 Team 策略和工具。descriptor 将它们识别为非成员后,调用会被拒绝。安装时序修复留待以后处理。
 
 这些限制说明策略与工具无法为一支团队保证什么。它们是当前包约束,不是与其他协作方式的对比。
 

+ 3 - 1
packages/experimental/tool-agent-team/package.json

@@ -53,6 +53,8 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:^"
   }
 }

+ 5 - 5
packages/experimental/tool-agent-team/src/index.ts

@@ -164,10 +164,7 @@ function install(agent: Agent, ctx: Context, config: Required<Config>): () => vo
     register(scoped.systemPrompt.section({
       name: 'team:policy',
       order: scoped.systemPrompt.getSectionOrder('TEAM_POLICY'),
-      text: () => {
-        const membership = ctx.agentTeams.membership(agent)
-        return `${POLICY}\n\nYour Team role is ${membership.role}; your Team name is ${membership.name}; Team id is ${membership.id}.`
-      },
+      text: POLICY,
     }))
 
     register(scoped.tools.register(defineTool({
@@ -190,7 +187,10 @@ function install(agent: Agent, ctx: Context, config: Required<Config>): () => vo
         return await ctx.agentTeams.spawnTeammate(agent, {
           name: args.name,
           description: args.description,
-          prompt: [{ type: 'text', text: args.prompt }],
+          prompt: [
+            { type: 'text', text: `<system-reminder>\nYou are teammate "${args.name.trim()}".\n</system-reminder>\n\n` },
+            { type: 'text', text: args.prompt },
+          ],
           context,
           provider: context === 'fork' ? config.forkProvider : config.freshProvider,
           signal: exec.signal,

+ 202 - 13
packages/experimental/tool-agent-team/tests/tool-team.spec.ts

@@ -6,18 +6,19 @@ import { Context } from '@deepseek-ai/cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { scopeOf } from '@deepseek-ai/dsh-scope'
-import { SessionId } from '@deepseek-ai/dsh-session'
+import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
-import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt'
 import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
 import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
-import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { serializeRequest } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import TeamService from '../../agent-team/src/index.ts'
 import * as toolTeam from '../src/index.ts'
 
@@ -35,6 +36,7 @@ const TOOL_NAMES = [
 ].sort()
 
 const roots: string[] = []
+const contexts = new Set<Context>()
 let callNumber = 0
 
 /** Session query implementation whose search faces are outside these tests. */
@@ -48,12 +50,15 @@ class TestSessionQuery extends SessionQueryEngine {
   }
 }
 
-afterEach(() => {
+afterEach(async () => {
+  for (const ctx of contexts) await ctx.fiber.dispose()
+  contexts.clear()
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
 async function setup(script: ConstructorParameters<typeof MockAdapter>[0], legacyControl = false) {
   const ctx = new Context()
+  contexts.add(ctx)
   await mountAgentLoopTestDependencies(ctx)
   const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-'))
   roots.push(storageRoot)
@@ -69,7 +74,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0], legac
   const adapter = new MockAdapter(script)
   ctx.llm.registerAdapter(['mock'], adapter)
   const lead = await ctx.agentLoop.create(SessionId('tool-team-lead'), { provider: 'mock', model: 'mock' })
-  return { ctx, lead, fiber }
+  return { ctx, lead, fiber, adapter }
 }
 
 function execute(
@@ -110,6 +115,11 @@ async function assembly(ctx: Context, agent: Agent) {
   return ctx.systemPrompt.assemble({ scope })
 }
 
+async function runTurn(agent: Agent, text: string): Promise<void> {
+  agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
+  await agent.whenIdle()
+}
+
 async function waitRunning(ctx: Context, id: SessionId): Promise<Agent> {
   return vi.waitFor(() => {
     const child = ctx.agents.get(id)
@@ -134,25 +144,29 @@ describe('dsh-tool-team', () => {
     expect(leadPrompt).toContain('Bash, formatters, code generators, and scripts are not fully protected')
     expect(leadPrompt).toContain('Task readiness never starts an owner')
     expect(leadPrompt).toContain('returns noProgress immediately')
-    expect(leadPrompt).toContain('Your Team role is lead')
+    expect(leadPrompt).not.toContain('Your Team role')
+    expect(renderContextSnapshot(leadAssembly)).toBe('')
 
     const spawned = await execute(ctx, lead, 'spawn_teammate', {
       name: 'tool-worker',
       description: 'exercise scoped tools',
       prompt: 'stay available',
     })
-    expect(spawned.isError).toBe(false)
+    expect(spawned.isError, text(spawned)).toBe(false)
     const childId = spawnedChildId(spawned)
     const child = await waitRunning(ctx, childId)
     const childAssembly = await assembly(ctx, child)
     expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort())
       .toEqual(TOOL_NAMES)
-    expect(renderPrompt(childAssembly)).toContain('Your Team role is teammate; your Team name is tool-worker')
+    expect(renderPrompt(childAssembly)).toBe(leadPrompt)
+    expect(renderContextSnapshot(childAssembly)).not.toContain('team:identity')
+    expect(child.session.deriveMessages().some(message => message.content.some(block =>
+      block.type === 'text' && block.text === '<system-reminder>\nYou are teammate "tool-worker".\n</system-reminder>\n\n'))).toBe(true)
     const initialPrompt = child.session.snapshotEvents().find(event => event.type === 'user/message'
       && event.data.source.kind === 'user')
     expect(initialPrompt?.type === 'user/message'
       ? initialPrompt.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
-      : []).toEqual(['stay available'])
+      : []).toEqual(['<system-reminder>\nYou are teammate "tool-worker".\n</system-reminder>\n\n', 'stay available'])
 
     const denied = await execute(ctx, child, 'spawn_teammate', {
       name: 'nested', description: 'not allowed', prompt: 'no',
@@ -163,6 +177,174 @@ describe('dsh-tool-team', () => {
     await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 })
   })
 
+  it.each([
+    ['fresh', undefined], ['fork', undefined], ['fork', 'in-history'],
+  ] as const)('records %s teammate identity with update mode %s and keeps the wire prefix', async (mode, systemPromptUpdate) => {
+    const { ctx, lead, adapter } = await setup([textResponse('parent answer'), textResponse('child answer')])
+    if (systemPromptUpdate !== undefined) adapter.systemPromptUpdate = systemPromptUpdate
+    await runTurn(lead, 'Parent task')
+    const parentRequest = serializeRequest(adapter.requests[0]!)
+    const parentHistory = structuredClone(lead.session.deriveMessages())
+    const spawned = await execute(ctx, lead, 'spawn_teammate', {
+      name: 'reviewer', description: 'review', prompt: 'Review the work', context: mode,
+    })
+    expect(spawned.isError, text(spawned)).toBe(false)
+    const childId = spawnedChildId(spawned)
+    await waitNoAgent(ctx, childId)
+    const childRequest = serializeRequest(adapter.requests[1]!)
+    expect(childRequest.tools).toEqual(parentRequest.tools)
+    expect(childRequest.messages[0]).toEqual(parentRequest.messages[0])
+    expect(JSON.stringify(childRequest.messages[0])).not.toContain('Your Team role')
+    if (mode === 'fork') {
+      expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages)
+      expect(adapter.requests[1]!.messages.slice(0, parentHistory.length)).toEqual(parentHistory)
+    } else {
+      expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task')
+    }
+    expect(childRequest.messages).toContainEqual({
+      role: 'user',
+      content: '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\nReview the work',
+    })
+    await using persisted = await ctx.sessionPersistence.open(childId, 'read')
+    const { events } = await persisted.read()
+    const initial = events.findLast(event => event.type === 'user/message' && event.data.source.kind === 'user')
+    expect(initial?.type === 'user/message' ? initial.data.content : []).toEqual([
+      { type: 'text', text: '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\n' },
+      { type: 'text', text: 'Review the work' },
+    ])
+  })
+
+  it('keeps an ordinary Lead fork free of identity reminders without replacing the inherited prefix', async () => {
+    const { ctx, lead, adapter } = await setup([textResponse('parent answer'), textResponse('fork answer')])
+    await runTurn(lead, 'Parent task')
+    const seed = lead.session.snapshotEvents()
+    const childId = SessionId('ordinary-team-fork')
+    const handle = await ctx.agents.create({
+      sessionId: childId,
+      seed,
+      inheritedEventCount: SessionLogOffset(seed.length),
+      meta: { parentSession: lead.id, isSeeded: true },
+      agentOptions: { provider: 'mock', model: 'mock' },
+      signal: SIGNAL,
+    })
+    await runTurn(handle.agent, 'Continue independently')
+    const parentRequest = serializeRequest(adapter.requests[0]!)
+    const childRequest = serializeRequest(adapter.requests[1]!)
+    expect(childRequest.tools).toEqual(parentRequest.tools)
+    expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages)
+    expect(childRequest.messages.at(-1)?.content).toBe('Continue independently')
+    expect(JSON.stringify(childRequest.messages)).not.toContain('system-reminder')
+    expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed)
+    expect(ctx.agentTeams.listMembers(handle.agent).map(member => member.name)).toEqual(['lead'])
+    await handle.dispose()
+  })
+
+  it.each([undefined, 'in-history'] as const)('keeps a teammate fork prefix with update mode %s without a Lead correction', async (systemPromptUpdate) => {
+    const { ctx, lead, adapter } = await setup([textResponse('review done'), textResponse('lead notified'), textResponse('fork answer')])
+    if (systemPromptUpdate !== undefined) adapter.systemPromptUpdate = systemPromptUpdate
+    const spawned = await execute(ctx, lead, 'spawn_teammate', {
+      name: 'reviewer', description: 'review', prompt: 'Review the work',
+    })
+    const teammateId = spawnedChildId(spawned)
+    await waitNoAgent(ctx, teammateId)
+    await using persisted = await ctx.sessionPersistence.open(teammateId, 'read')
+    const { events: seed } = await persisted.read()
+    const handle = await ctx.agents.create({
+      sessionId: SessionId('fork-from-teammate'),
+      seed,
+      inheritedEventCount: SessionLogOffset(seed.length),
+      meta: { parentSession: teammateId, isSeeded: true },
+      agentOptions: { provider: 'mock', model: 'mock' },
+      signal: SIGNAL,
+    })
+    try {
+      const inheritedHistory = structuredClone(handle.agent.session.deriveMessages())
+      await runTurn(handle.agent, 'Continue independently')
+      const original = serializeRequest(adapter.requests.find(request => request.sessionId === teammateId)!)
+      const forkRequest = adapter.requests.find(request => request.sessionId === handle.agent.id)!
+      const fork = serializeRequest(forkRequest)
+      expect(fork.tools).toEqual(original.tools)
+      expect(fork.messages.slice(0, original.messages.length)).toEqual(original.messages)
+      expect(forkRequest.messages.slice(0, inheritedHistory.length)).toEqual(inheritedHistory)
+      expect(fork.messages).toContainEqual({ role: 'user', content: 'Continue independently' })
+      expect(JSON.stringify(fork.messages)).not.toContain('You are the Team Lead')
+      expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed)
+    } finally {
+      await handle.dispose()
+    }
+  })
+
+  it('leaves initial identity to ordinary history compaction without reinserting it', async () => {
+    const { ctx, lead, adapter } = await setup([
+      toolCallResponse('first-list', 'list_agents', {}),
+      toolCallResponse('second-list', 'list_agents', {}),
+      textResponse('done'),
+    ])
+    ctx.on('agent/pre-step', async ({ agent, step }, next) => {
+      if (step === 3) {
+        const identity = agent.session.snapshotEvents().find(event => event.type === 'user/message'
+          && event.data.source.kind === 'user')
+        if (identity === undefined) throw new Error('expected initial teammate reminder')
+        agent.session.append('user/message', createUserMessage({
+          content: [{ type: 'text', text: 'Compacted earlier context.' }],
+          source: { kind: 'plugin', plugin: 'test-compaction' },
+        }), {
+          surfaceOp: { op: 'replace', startSeq: identity.seq, endSeq: identity.seq },
+          sourceEventSeqs: [identity.seq],
+        })
+      }
+      return next()
+    })
+    const spawned = await execute(ctx, lead, 'spawn_teammate', {
+      name: 'reviewer', description: 'review', prompt: 'Review the work',
+    })
+    const childId = spawnedChildId(spawned)
+    await waitNoAgent(ctx, childId)
+    const reminder = '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\n'
+    const first = serializeRequest(adapter.requests[0]!).messages
+    expect(first).toContainEqual({ role: 'user', content: `${reminder}Review the work` })
+    expect(adapter.requests[1]!.messages.filter(message => message.content.some(block =>
+      block.type === 'text' && block.text === reminder))).toHaveLength(1)
+    expect(JSON.stringify(serializeRequest(adapter.requests[2]!).messages)).not.toContain('You are teammate')
+    await using persisted = await ctx.sessionPersistence.open(childId, 'read')
+    const { events } = await persisted.read()
+    expect(events.filter(event => event.type === 'user/message'
+      && event.data.source.kind === 'plugin' && event.data.source.plugin === toolTeam.name)).toHaveLength(0)
+  })
+
+  it.each(['reject', 'empty', 'abort'] as const)('does not revive a teammate step after %s', async (mode) => {
+    const { ctx, lead, adapter } = await setup([])
+    ctx.on('agent/pre-step', async ({ agent }, next) => {
+      const decision = await next()
+      if (agent === lead) return decision
+      if (mode === 'reject') return { kind: 'reject' }
+      if (mode === 'abort') agent.cancel({ kind: 'user' })
+      return { kind: 'enter', messages: [] }
+    })
+    await execute(ctx, lead, 'spawn_teammate', {
+      name: 'reviewer', description: 'review', prompt: 'Review the work',
+    })
+    const childId = SessionId(ctx.agentTeams.listMembers(lead).find(member => member.name === 'reviewer')!.id)
+    await waitNoAgent(ctx, childId)
+    expect(adapter.requests.filter(request => request.sessionId === childId)).toEqual([])
+    await using persisted = await ctx.sessionPersistence.open(childId, 'read')
+    const { events } = await persisted.read()
+    expect(events.filter(event => event.type === 'user/message'
+      && event.data.source.kind === 'user')).toEqual([])
+  })
+
+  it('keeps teammate reminders when runtime context is suppressed', async () => {
+    const { ctx, lead, adapter } = await setup([textResponse('worker done')])
+    ctx.systemPrompt.suppressRuntimeContext()
+    const spawned = await execute(ctx, lead, 'spawn_teammate', {
+      name: 'reviewer', description: 'review', prompt: 'Review the work',
+    })
+    expect(spawned.isError, text(spawned)).toBe(false)
+    await waitNoAgent(ctx, spawnedChildId(spawned))
+    expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content)
+      .toBe('<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\nReview the work')
+  })
+
   it('returns actionable no-progress output and renders structured wait cancellation', async () => {
     const inactiveSetup = await setup([textResponse('worker done')])
     const inactiveSpawn = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'spawn_teammate', {
@@ -393,7 +575,7 @@ describe('dsh-tool-team', () => {
     const assembled = await assembly(ctx, lead)
     expect(assembled.tools.filter(schema => TOOL_NAMES.includes(schema.name)).map(schema => schema.name))
       .toEqual(['spawn_teammate'])
-    expect(renderPrompt(assembled)).not.toContain('Your Team role is lead')
+    expect(renderContextSnapshot(assembled)).not.toContain('Your Team role is lead')
   })
 
   it('resolves direct-apply defaults without Loader schema normalization', async () => {
@@ -412,7 +594,7 @@ describe('dsh-tool-team', () => {
   })
 
   it('reinstalls Team scope before a cold-resumed teammate request', async () => {
-    const { ctx, lead } = await setup([textResponse('first'), 'hang'])
+    const { ctx, lead, adapter } = await setup([textResponse('first'), 'hang', 'hang'])
     const spawned = await execute(ctx, lead, 'spawn_teammate', {
       name: 'cold-worker', description: 'cold worker', prompt: 'finish once',
     })
@@ -427,7 +609,14 @@ describe('dsh-tool-team', () => {
     const resumed = await waitRunning(ctx, childId)
     expect((await assembly(ctx, resumed)).tools.map(schema => schema.name)
       .filter(name => TOOL_NAMES.includes(name)).sort()).toEqual(TOOL_NAMES)
-    expect(renderPrompt(await assembly(ctx, resumed))).toContain('Your Team role is teammate; your Team name is cold-worker')
+    expect(renderContextSnapshot(await assembly(ctx, resumed))).not.toContain('You are teammate')
+    const childRequests = () => adapter.requests.filter(request => request.messages.some(message => message.role === 'user'
+      && message.content.some(block => block.type === 'text' && block.text.includes('You are teammate "cold-worker".'))))
+    await vi.waitFor(() => { expect(childRequests()).toHaveLength(2) })
+    const firstMessages = childRequests()[0]!.messages
+    expect(childRequests()[1]!.messages.slice(0, firstMessages.length)).toEqual(firstMessages)
+    expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message'
+      && event.data.source.kind === 'user')).toHaveLength(1)
     await execute(ctx, lead, 'interrupt_agent', { target: 'cold-worker' })
     await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 })
   })

+ 2 - 2
packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md
-README.md: 10f0b01d147dfd0afefd2214264084e55d2b213d
-README.zh.md: 14479e688f122712041741270b995ca47a2e1166
+README.md: 236ae18c35b4aaa633e8ab7c8e9065b3cfae1604
+README.zh.md: f2018d08c624862c032b7691b161a26ee7461e9d

+ 1 - 1
packages/subprocess/subprocess-local/README.md

@@ -52,7 +52,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina
 
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 
-Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. A failed final signal does not reject a range subsequently proven empty; active ranges without that proof retain the signal failure.
+Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. After termination, a consumed launch request and zero scope processes prove quiescence even before the direct-process exit notification. A failed final signal does not reject a range subsequently proven empty; active ranges without that proof retain the signal failure.
 
 ### What can go wrong
 

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
 
-Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。即使最终信号发送失败,之后证明范围已为空仍可成功结束;未获得这一证明的 active 范围仍报告信号失败。
+Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。请求终止后,启动请求已消费且 scope 进程数为零即可证明完全停稳,无需等待直接进程的退出通知。即使最终信号发送失败,之后证明范围已为空仍可成功结束;未获得这一证明的 active 范围仍报告信号失败。
 
 ### 可能出错的地方
 

+ 5 - 4
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -262,12 +262,13 @@ class SystemdScopeOwner implements BoundProcessOwner {
    * Prove an active unit with no processes is the empty managed range rather
    * than a launch still placing its payload. systemd ends a scope only on the
    * populated-to-empty transition, so a payload killed before it entered the
-   * cgroup leaves the unit active forever. Requested termination plus a
-   * departed client makes that leftover conclusive: the client forked every
-   * process it will ever fork.
+   * cgroup leaves the unit active forever. A departed client cannot add another
+   * payload; a consumed request proves the payload already entered the scope,
+   * even while its direct-process exit notification is pending.
    */
   private emptyRange(tasksCurrent: number | undefined): boolean {
-    return this.terminationRequested && tasksCurrent === 0 && !this.direct.running()
+    return this.terminationRequested && tasksCurrent === 0
+      && (!this.direct.running() || !existsSync(this.startup.files.requestPath))
   }
 
   /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */

+ 20 - 1
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -487,6 +487,25 @@ describe('Linux scope establishment and quiescence', () => {
     launched.result.owner.cleanup?.()
   })
 
+  it('settles a consumed empty scope before its launcher reports exit after a failed kill', async () => {
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+      .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
+    const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    try {
+      await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+      expect(launched.child.exitCode).toBeNull()
+      expect(launched.child.signalCode).toBeNull()
+      expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
+    } finally {
+      launched.child.exit(null, 'SIGKILL')
+      await launched.result.direct
+      launched.result.owner.cleanup?.()
+    }
+  })
+
   it.each([
     { tasks: '1', clientRunning: false },
     { tasks: '[not set]', clientRunning: false },
@@ -496,7 +515,7 @@ describe('Linux scope establishment and quiescence', () => {
     const spawnSync = recordingSystemctl()
       .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
     const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
-    consumeLinuxLaunchRequest(launched.requestPath)
+    if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
     launched.result.owner.signal('SIGKILL')
     if (!clientRunning) launched.child.exit(null, 'SIGKILL')
     await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')

+ 6 - 0
pnpm-lock.yaml

@@ -5617,6 +5617,12 @@ importers:
       '@deepseek-ai/dsh-experimental-agent-team':
         specifier: workspace:^
         version: link:../agent-team
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:^
+        version: link:../../llm/llm-deepseek
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope

+ 18 - 2
snapshots/web/message-actions/ui.expected.md

@@ -11,6 +11,7 @@
 - navigation "Turn navigation":
   - button "Jump to turn 1"
   - button "Jump to turn 2"
+  - button "Jump to turn 3"
 - button "System prompt":
   - img
   - img
@@ -55,6 +56,21 @@
 - button "Copy":
   - img
 - paragraph: DONE
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}} Keep this later input in the original conversation. {{clock}}
+- button "Copy":
+  - img
+- paragraph: ORIGINAL ONLY
 - button "Copy":
   - img
 - button "Good response":
@@ -75,9 +91,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "2 turns 3 steps · {{throughput}} tok/s":
+- button "3 turns 4 steps · {{throughput}} tok/s":
   - img
-  - text: 2 turns 3 steps{{throughput}} tok/s
+  - text: 3 turns 4 steps{{throughput}} tok/s
 - button "7.9K tok · Cache hit 98%":
   - img
   - text: 7.9K tokCache hit 98%

+ 8 - 0
snapshots/web/workspace-recency/manual-blank.expected.md

@@ -0,0 +1,8 @@
+- tree "Sessions":
+  - treeitem "{{workspace}}" [expanded]:
+    - img
+    - text: {{workspace}}
+  - treeitem "New Session" [selected]
+  - treeitem "Newest conversation now"
+  - treeitem "Middle conversation 1min"
+  - treeitem "Oldest conversation 2min"

+ 7 - 0
snapshots/web/workspace-recency/sidebar.expected.md

@@ -0,0 +1,7 @@
+- tree "Sessions":
+  - treeitem "{{workspace}}" [expanded]:
+    - img
+    - text: {{workspace}}
+  - treeitem "Newest conversation now" [selected]
+  - treeitem "Middle conversation 1min"
+  - treeitem "Oldest conversation 2min"

+ 9 - 0
snapshots/web/workspace-recency/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: workspace-recency
+profile: web
+composition: web-default
+recording: authored
+header:
+  class: web-default
+session:
+  source: ../seeded-history/session.v3.jsonl

+ 1 - 0
tsconfig.host.json

@@ -43,6 +43,7 @@
     "apps/web/tests/remote-welcome.e2e.ts",
     "apps/web/tests/workspace-new-session-folding.e2e.ts",
     "apps/web/tests/workspace-management.e2e.ts",
+    "apps/web/tests/workspace-recency.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",
     "apps/web/tests/hmr-live.e2e.ts",
     "apps/web/tests/preview-boot.e2e.ts",

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov