Преглед на файлове

fix(web): address latest session reveal review

Dudu-0223 преди 1 месец
родител
ревизия
d18770f50a

+ 2 - 2
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.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/bug-fix/2026-09-03-session-search-result-reveal.md
-2026-09-03-session-search-result-reveal.md: 71511ba84f6e33eece8c64c4a20a8ea5eac7ceb9
-2026-09-03-session-search-result-reveal.zh.md: aeea1b6b31f61771579ad0061eae858d23dcdcd8
+2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0
+2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0

+ 6 - 4
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md

@@ -11,9 +11,9 @@ Selecting a Session search result opened its conversation while leaving the side
 
 ## Decision
 
-[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It opens the owning Workspace or Ungrouped group, records the target Session id, clears the query, collapses search, and opens the Session. When grouped browsing remounts, the target group starts with its hidden remainder transiently revealed. Flat browsing needs no fold override.
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override.
 
-The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Metadata and content matches use the same transition because both result kinds resolve to a Session id.
+The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id.
 
 ## Alternatives considered
 
@@ -27,8 +27,10 @@ The normal Session row owns completion of the one-shot reveal. A matching mounte
 
 ## Consequences
 
-Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount, so the selected row supplies both hierarchy context and an on-screen location. A later search or ordinary render does not repeat the scroll after acknowledgment.
+Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment.
+
+The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts.
 
 ## Testing
 
-UI tests cover a content-only hit in the sixth position of a closed Workspace, the corresponding transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition.
+UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition.

+ 6 - 4
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md

@@ -11,9 +11,9 @@ Archived: 2026-09-03
 
 ## 决策
 
-[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会打开所属 Workspace 或 Ungrouped 分组、记录目标 Session id、清空查询、收起搜索并打开 Session。分组浏览重新挂载时,目标分组会以临时显露其余隐藏条目的状态启动。单列表浏览不需要覆盖折叠状态。
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。
 
-常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。
+常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。
 
 ## 考虑过的替代方案
 
@@ -27,8 +27,10 @@ Archived: 2026-09-03
 
 ## 后果
 
-选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,其隐藏条目在该次树挂载期间可见,因此选中行同时提供层级上下文与屏幕内位置。确认完成后,后续搜索或普通渲染不会重复滚动。
+选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。
+
+目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。
 
 ## 测试
 
-UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、对应的临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。
+UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。

+ 3 - 3
.agents/notes/archived/manifest.json

@@ -139,9 +139,9 @@
     "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16",
-    "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:36fea5421f6f9a3e20a32db8283db7f532433267c1d17ed49b0ec8dbd1fc1765",
-    "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:1c80c4cea1193ecdfccd17efc5702255af17ea68c3662b40bced7c70558bf231",
-    "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:bf0ff3d9bc262ec83d9930d10da839fe9d1c47b483dc9406e6d065876f9e0cdc",
+    "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
+    "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
+    "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
     "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
     "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
     "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
-2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f
-2026-07-27-trajectory-inspection-ledger.zh.md: 6a38147a075480882db5c39a0810404654c6c849
+2026-07-27-trajectory-inspection-ledger.md: 75f8480d3e52c6cc1f2eefd7ef9d5a2c03b40dee
+2026-07-27-trajectory-inspection-ledger.zh.md: 82af4904d90a6fbfc811d176dd4b859b26d4774c

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md

@@ -23,7 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
 - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
 - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes.
 - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
-- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
+- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. The virtualizer's stable-key `followOnAppend` behavior owns structural appends while the user remains at the bottom; the component writes the tail position itself only for a non-virtual ledger. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
 - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.
 - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger.
 - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md

@@ -23,7 +23,7 @@ Status: implemented
 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早的 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失。
 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
-- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
+- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。用户保持在底部时,虚拟化器基于稳定键的 `followOnAppend` 行为负责结构性追加;组件只对非虚拟记录表自行写入末尾位置。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。
 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。
 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
-2026-08-11-workspace-sidebar-order-and-folding.md: 99b0dcaf978411345868a2359c537b677ff771f0
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: b8381e28a62859bdc91aa037e79a276761f9132d
+2026-08-11-workspace-sidebar-order-and-folding.md: a8026936905d42ccf5f30a02684310324d8ee359
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: 83a66c9b4a5f8dd34b56c6d8a8af2d18f77e983b

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md

@@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio
 
 ### Session folding and view order
 
-Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result opens its owning group after Workspace membership is ready and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
+Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result waits for the current Workspace stream's complete Host baseline before opening its owning group, and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
 
 The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
 
@@ -56,4 +56,4 @@ Search is a header action while collapsed and expands across the title and trail
 
 ## Testing
 
-Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.
+Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, initial and reconnecting Workspace-baseline waits, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md

@@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 ### Session 折叠与视图顺序
 
-每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等 Workspace 归属信息就绪后打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
+每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等待当前 Workspace stream 取得完整 Host 基线后再打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
 
 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
 
@@ -56,4 +56,4 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
 
 ## 测试
 
-领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。
+领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、初始与重连 Workspace 基线等待、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。

+ 1 - 2
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -34,7 +34,6 @@ const FIXTURE = createChatScrollFixture({
   turns: 88,
 })
 const MAX_MOUNTED_ROWS = 160
-const MAX_STREAM_SCROLL_CALLS = 8
 const GEOMETRY_TOLERANCE = 2
 const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
 const STREAM_TEXT = Array.from(
@@ -343,7 +342,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
         return (window as Window & { __trajectoryScrollCalls?: number })
           .__trajectoryScrollCalls ?? 0
       })
-      expect(streamingScrollCalls).toBeLessThanOrEqual(MAX_STREAM_SCROLL_CALLS)
+      expect(streamingScrollCalls).toBeLessThanOrEqual(5)
       expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
       expect({
         pageErrors: tripwire.pageErrors,

+ 2 - 2
packages/client/ui-trajectory/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-trajectory/README.md
-README.md: d947787c123fca77dc7978456f4cfdc4e99951bf
-README.zh.md: 3b281c6c1e90108d66f050e07a3b08208ed8c802
+README.md: d61a100842f9457579dc014b94f541e9809e5a2d
+README.zh.md: abc778af44203550c1fb8a9343ad9735ba2a84a2

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

@@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
 
 ### Virtual rows
 
-Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
+Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. The virtualizer owns bottom following for structural appends; non-virtual ledgers use a direct tail-position write. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
 
 ### Layout
 

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

@@ -47,7 +47,7 @@ kind: "package-reference"
 
 ### 虚拟行
 
-长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
+长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。虚拟化器负责结构性追加后的底部跟随;非虚拟记录表会直接写入末尾位置。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
 
 ### 布局
 

+ 2 - 2
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1909,6 +1909,7 @@ export function TrajectoryTable({
     overscan: VIRTUAL_OVERSCAN_ROWS,
     scrollMargin: virtualScrollMargin,
     scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
+    followOnAppend: 'auto',
   })
   const virtualIndexByRecordId = useMemo(() => {
     const indexes = new Map<string, number>()
@@ -2284,8 +2285,7 @@ export function TrajectoryTable({
       return
     }
     if (!followsTableTail.current) return
-    if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
-    else pane.scrollTop = pane.scrollHeight
+    if (!virtualizationEnabled) pane.scrollTop = pane.scrollHeight
   }, [
     historyLoading,
     historyStartSeq,

+ 3 - 2
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -240,7 +240,7 @@ type SessionTreeProps = Pick<
   /** Host account home for POSIX hover-path abbreviation. */
   home?: string | undefined
   workspaces: readonly WorkspaceView[]
-  /** Whether Workspace membership has received its first complete Host baseline. */
+  /** 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>>
@@ -859,6 +859,7 @@ export function WorkspaceBrowser({
   const home = useHostInfo(info => info.home)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
+  const workspaceStreamState = useWorkspaces(state => state.state)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
   // Live occupancy of this surface's directory-flow hole (the same source the
   // flow reads): a composition without a picking affordance can add nothing.
@@ -1286,7 +1287,7 @@ export function WorkspaceBrowser({
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}
                 workspaces={workspaces}
-                workspaceReady={workspacePhase === 'ready'}
+                workspaceReady={workspacePhase === 'ready' && workspaceStreamState !== 'loading'}
                 groupExpansion={groupExpansion}
                 setGroupExpanded={actions.setGroupExpanded}
                 sessionOrderByAccount={sessionOrderByAccount}

+ 33 - 0
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -755,6 +755,39 @@ describe('WorkspaceBrowser', () => {
     expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
   })
 
+  it('waits for the reconnect baseline before resolving reveal membership', async () => {
+    const sessions = sessionState([
+      summary('newest-1', 6),
+      summary('newest-2', 5),
+      summary('newest-3', 4),
+      summary('newest-4', 3),
+      summary('newest-5', 2),
+      summary('target', 1, { displayTitle: 'Needle session' }),
+    ])
+    const reconnecting = {
+      ...workspaceState([workspace('stale', [...sessions.ids])]),
+      state: 'loading' as const,
+    }
+    const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) })
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    expect(b.store.getSnapshot().groupExpansion).toEqual({})
+    expect(scrollIntoView).not.toHaveBeenCalled()
+
+    rerender(b, {
+      useWorkspaces: hook(workspaceState([workspace('current', [...sessions.ids])])),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().groupExpansion).toEqual({ current: true })
+      expect(screen.getByText('Needle session')).toBeTruthy()
+    })
+    const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]')
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow)
+    expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale')
+  })
+
   it('keeps the bounded group projection when the revealed result is already within it', () => {
     const sessions = sessionState([
       summary('target', 6, { displayTitle: 'Needle session' }),