Kaynağa Gözat

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

Dudu-0223 1 hafta önce
ebeveyn
işleme
22b843ed2b
40 değiştirilmiş dosya ile 1214 ekleme ve 544 silme
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
  4. 2 2
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
  5. 1 1
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md
  6. 1 1
      .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md
  7. 6 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml
  8. 39 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
  9. 39 0
      .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md
  10. 1 1
      apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md
  11. 56 7
      apps/web/tests/message-actions.e2e.ts
  12. 167 0
      apps/web/tests/workspace-recency.e2e.ts
  13. 1 0
      apps/web/tsconfig.json
  14. 2 2
      packages/api/session-controller/README.i18n.yaml
  15. 2 0
      packages/api/session-controller/README.md
  16. 2 0
      packages/api/session-controller/README.zh.md
  17. 1 4
      packages/api/session-controller/src/commands.ts
  18. 65 3
      packages/api/session-controller/tests/session-fork.host.spec.ts
  19. 2 2
      packages/client/connection/README.i18n.yaml
  20. 2 0
      packages/client/connection/README.md
  21. 2 0
      packages/client/connection/README.zh.md
  22. 1 2
      packages/client/connection/src/client/fixture.ts
  23. 29 0
      packages/client/connection/tests/fixture.client.spec.ts
  24. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  25. 3 3
      packages/client/ui-workspace/README.md
  26. 3 3
      packages/client/ui-workspace/README.zh.md
  27. 0 6
      packages/client/ui-workspace/src/client/contract/slots.ts
  28. 0 3
      packages/client/ui-workspace/src/client/index.ts
  29. 6 5
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  30. 148 236
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  31. 34 19
      packages/client/ui-workspace/src/client/stores.ts
  32. 112 52
      packages/client/ui-workspace/src/client/tree.ts
  33. 2 5
      packages/client/ui-workspace/tests/apply.client.spec.ts
  34. 83 31
      packages/client/ui-workspace/tests/tree.client.spec.ts
  35. 293 152
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  36. 18 2
      snapshots/web/message-actions/ui.expected.md
  37. 8 0
      snapshots/web/workspace-recency/manual-blank.expected.md
  38. 7 0
      snapshots/web/workspace-recency/sidebar.expected.md
  39. 9 0
      snapshots/web/workspace-recency/snapshot.yml
  40. 1 0
      tsconfig.host.json

+ 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/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 场景在实际发布组合中验证持久化位置、原生拖拽、分组与单列表视图、固定首位的空白项及重新加载。既有折叠用例保留临时空白行配额。

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

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

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