From d0b7dea8af447319e52627d4b39da772f4d0540e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 12:51:51 +0800 Subject: [PATCH 1/5] fix(ui-workspace): pin the current blank New Session row first Clicking New Session reuses the workspace's existing blank session when one exists; reuse only opens it, so its updatedAt (host: max(createdAt, lastPromptAt)) never advances and the row kept its old creation-time position in the sidebar. nextSessionOrderAccount now pins the currently selected blank session to the front of its account in both Manual and Last updated modes; non-blank navigation stays untouched. Fixes #2788 --- .../owner-running.expected.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 14 +- .../tests/workspace-browser.client.spec.tsx | 123 ++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md index 7e1864ff58..62671cbb8b 100644 --- a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md +++ b/apps/web/tests/snapshots/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" diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 9681f676ac..2a1ec9255b 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -103,7 +103,13 @@ function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListStat return a < b ? -1 : 1 } -/** Reconcile one editable order account and apply its activity-promotion policy. */ +/** + * Reconcile one editable order account and apply its activity-promotion + * policy. The blank session the user is currently in (the New Session being + * created) is pinned first in every mode: opening or reusing a blank never + * advances its `updatedAt`, so without the pin the row would keep its old + * creation-time position. + */ function nextSessionOrderAccount({ sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency, }: { @@ -130,6 +136,12 @@ function nextSessionOrderAccount({ order = [...promoted, ...order.filter(id => !promotedIds.has(id))] } } + // The New Session being created stays first in its account while it is the + // current selection, in both order modes. + const current = list.current + if (current !== undefined && list.byId[current]?.blank === true && sessionIds.includes(current)) { + order = [current, ...order.filter(id => id !== current)] + } const updatedAt: Record = {} for (const id of sessionIds) { const session = list.byId[id] diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 9b5875d8cd..e9511d5b6b 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -95,6 +95,11 @@ function rerender(b: ReturnType, overrides: Partial) } +/** Every tree row's text, group headers included, in render order. */ +function rowsOf(_b: ReturnType): (string | null)[] { + return screen.getAllByRole('treeitem').map(row => row.textContent) +} + describe('WorkspaceBrowser', () => { it('workspace hover card shows a POSIX home descendant as ~', () => { vi.useFakeTimers() @@ -468,6 +473,124 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('新会话')).toBeNull() }) + it('pins the current blank New Session row first in Last-updated mode (workspace view)', async () => { + const b = mount({ + useSessions: hook(sessionState([ + summary('old', 100), + summary('blank', 150, { blank: true }), + summary('mid', 200), + ])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) + fireEvent.click(screen.getByText('alpha')) + await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) + // The stale blank is hidden while it is not the current session. + expect(rowsOf(b)).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('mid'), + expect.stringContaining('old'), + ]) + // New Session reuses the blank: it becomes current and jumps to the top, + // even though its updatedAt (creation time) is older than mid's. + rerender(b, { + useSessions: hook(sessionState([ + summary('old', 100), + summary('blank', 150, { blank: true }), + summary('mid', 200), + ], { current: sid('blank') })), + }) + await waitFor(() => { + expect(rowsOf(b)).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('新会话'), + expect.stringContaining('mid'), + expect.stringContaining('old'), + ]) + }) + }) + + it('does not pin a non-blank current session in Last-updated mode', async () => { + const b = mount({ + useSessions: hook(sessionState([ + summary('old', 100), + summary('mid', 200), + ])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'mid'])])), + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) + fireEvent.click(screen.getByText('alpha')) + await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) + // Opening an ordinary session is navigation, not a New Session gesture: + // the selected row stays at its recency position. + rerender(b, { + useSessions: hook(sessionState([ + summary('old', 100), + summary('mid', 200), + ], { current: sid('old') })), + }) + await waitFor(() => { + expect(rowsOf(b)).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('mid'), + expect.stringContaining('old'), + ]) + }) + }) + + it('pins the current blank first in the flat list too', async () => { + const b = mount({ + useSessions: hook(sessionState([ + summary('old', 100), + summary('blank', 150, { blank: true }), + summary('mid', 200), + ], { current: sid('blank') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) + await waitFor(() => { + expect(rowsOf(b)).toEqual([ + expect.stringContaining('新会话'), + expect.stringContaining('mid'), + expect.stringContaining('old'), + ]) + }) + }) + + it('pins the current blank first in manual mode too', async () => { + const b = mount({ + useSessions: hook(sessionState([ + summary('old', 100), + summary('blank', 150, { blank: true }), + summary('mid', 200), + ], { current: sid('blank') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), + }) + expect(b.store.getSnapshot().orderBy).toBe('manual') + // The current session's group auto-expands; the New Session being + // created renders first even though the manual account order holds it + // in its creation-time slot. + await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) + // Manual order follows the Workspace account [old, blank, mid]; the + // current blank is pinned first, the rest keeps the account order. + await waitFor(() => { + expect(rowsOf(b)).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('新会话'), + expect.stringContaining('old'), + expect.stringContaining('mid'), + ]) + }) + }) + it('shows local metadata matches immediately, then clears back to the grouped tree', async () => { vi.useFakeTimers() try { From 98723525c6e8d19475f973f180d7b4775d736ae2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 12:51:59 +0800 Subject: [PATCH 2/5] docs(notes): record current-blank pinning in the sidebar order note The New Session being created renders first in its account while selected, in both order modes; opening or reusing a blank never advances its updatedAt, so the pin keeps the reused row at the front. --- .../2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml | 4 ++-- .../2026-08-11-workspace-sidebar-order-and-folding.md | 5 ++++- .../2026-08-11-workspace-sidebar-order-and-folding.zh.md | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index 1020c5a243..99c38130cd 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.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: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b -2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7 +2026-08-11-workspace-sidebar-order-and-folding.md: 6a72d5421d66f44ed8f745a280ef35db99988abe +2026-08-11-workspace-sidebar-order-and-folding.zh.md: d54fc7c5a2b9cc17cc0b2a1f607f31d3d9a898f2 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index d683d78245..6a72d5421d 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -24,6 +24,8 @@ Each Workspace persists one browser-local open state: closed means zero Session 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. +The blank Session that is currently selected — the New Session being created — is pinned to the first position of its account in both Manual and Last updated. Opening or reusing a blank never advances its `updatedAt` (the Host derives it from `createdAt` and `lastPromptAt`), so without the pin a reused blank would keep its old creation-time position; the pin keeps the row the user is actively working in at the front of its group or the flat list. + ### Drag and compact chrome Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. @@ -48,9 +50,10 @@ Search is a header action while collapsed and expands across the title and trail - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. +- The current blank New Session row renders first in its account while it is selected; every other row keeps its Manual or Last-updated position, and a non-blank current Session is never reordered by navigation. - Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). ## 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, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, 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. +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, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, pinning the current blank New Session row to its account front in both modes, non-blank navigation leaving positions untouched, 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. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index 99e1991cfb..d54fc7c5a2 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -24,6 +24,8 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 +当前选中的空白 Session(正在新建的"新会话")在**手动排序**和**最近更新**两种模式下都被置顶到其记账第一位。打开或复用空白不会推进其 `updatedAt`(Host 从 `createdAt` 与 `lastPromptAt` 推导),若不置顶,复用的空白会停留在创建时的旧位置;置顶让用户正在使用的新建行始终位于分组或单列表最前。 + ### 拖拽与紧凑界面 Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 @@ -48,9 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 +- 当前选中的空白"新会话"行在被选中期间渲染在其记账第一位;其余行保持手动排序或最近更新位置,点开非空白会话不会因导航而重排。 - 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、两种模式下把当前空白"新会话"行置顶到记账最前、点开非空白会话不改变位置、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 From d53292b30a40339413f912723a610c3fbef1b8d9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 13:25:55 +0800 Subject: [PATCH 3/5] fix(ui-workspace): make the blank-session pin render-only and skip masked drags Review follow-up on #2794: the pin was applied inside nextSessionOrderAccount, whose output syncSessionOrderAccount persists into sessionOrderByAccount, so a reused blank stayed first forever after turning non-blank (even across reloads). Move the pin to the render-derived layer (orderedWorkspaces, orderedUngroupedSessionIds, ungrouped order, flat rows) so the persisted account order is never touched, and skip drags the pin would mask (dragging the pinned blank, or parking another row into the pinned slot) entirely, including the Host account write. Adds regression tests for the masked-drag skip and for the pin releasing when the blank turns real; updates the sidebar-order Agent Note (EN + ZH) and its pairing hash. --- ...kspace-sidebar-order-and-folding.i18n.yaml | 4 +- ...-11-workspace-sidebar-order-and-folding.md | 6 +- ...-workspace-sidebar-order-and-folding.zh.md | 6 +- .../src/client/WorkspaceBrowser.tsx | 62 ++++++++---- .../tests/workspace-browser.client.spec.tsx | 98 +++++++++++++++++-- 5 files changed, 142 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index 99c38130cd..610f1702d6 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.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: 6a72d5421d66f44ed8f745a280ef35db99988abe -2026-08-11-workspace-sidebar-order-and-folding.zh.md: d54fc7c5a2b9cc17cc0b2a1f607f31d3d9a898f2 +2026-08-11-workspace-sidebar-order-and-folding.md: c09ec72be443be7fb22c42df98e42a73d83d468b +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 481066d438722568728587ad0764b193eb56650e diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 6a72d5421d..c09ec72be4 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -24,7 +24,7 @@ Each Workspace persists one browser-local open state: closed means zero Session 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. -The blank Session that is currently selected — the New Session being created — is pinned to the first position of its account in both Manual and Last updated. Opening or reusing a blank never advances its `updatedAt` (the Host derives it from `createdAt` and `lastPromptAt`), so without the pin a reused blank would keep its old creation-time position; the pin keeps the row the user is actively working in at the front of its group or the flat list. +The blank Session that is currently selected — the New Session being created — renders first in its account in both Manual and Last updated. Opening or reusing a blank never advances its `updatedAt` (the Host derives it from `createdAt` and `lastPromptAt`), so without the pin a reused blank would keep its old creation-time position. The pin is applied when deriving the render order and is never written back to the persisted per-account order: deselecting the blank or sending its first prompt restores its stored slot, and a drag the pin would mask (dragging the pinned blank itself, or parking another Session into the pinned slot) is skipped entirely, including the Host account write. ### Drag and compact chrome @@ -50,10 +50,10 @@ Search is a header action while collapsed and expands across the title and trail - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. -- The current blank New Session row renders first in its account while it is selected; every other row keeps its Manual or Last-updated position, and a non-blank current Session is never reordered by navigation. +- The current blank New Session row renders first in its account while it is selected, as a render-time pin that never touches the persisted order; every other row keeps its Manual or Last-updated position, and a non-blank current Session is never reordered by navigation. - Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). ## 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, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, pinning the current blank New Session row to its account front in both modes, non-blank navigation leaving positions untouched, 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. +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, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, pinning the current blank New Session row to its account front in both modes, non-blank navigation leaving positions untouched, masked drags of the pinned row being skipped without a browser reorder or Host write, the pin releasing when the blank turns real, 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. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index d54fc7c5a2..481066d438 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -24,7 +24,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 -当前选中的空白 Session(正在新建的"新会话")在**手动排序**和**最近更新**两种模式下都被置顶到其记账第一位。打开或复用空白不会推进其 `updatedAt`(Host 从 `createdAt` 与 `lastPromptAt` 推导),若不置顶,复用的空白会停留在创建时的旧位置;置顶让用户正在使用的新建行始终位于分组或单列表最前。 +当前选中的空白 Session(正在新建的"新会话")在**手动排序**和**最近更新**两种模式下都渲染在其记账第一位。打开或复用空白不会推进其 `updatedAt`(Host 从 `createdAt` 与 `lastPromptAt` 推导),若不置顶,复用的空白会停留在创建时的旧位置。置顶只作用于派生渲染顺序,绝不写回持久化的每记账顺序:取消选中空白或发出首条提示词后即恢复其存储位置;会被置顶掩盖的拖拽(拖动被置顶的空白本身,或把其他 Session 放入被置顶的槽位)会被整体跳过,包括 Host 记账写入。 ### 拖拽与紧凑界面 @@ -50,10 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 -- 当前选中的空白"新会话"行在被选中期间渲染在其记账第一位;其余行保持手动排序或最近更新位置,点开非空白会话不会因导航而重排。 +- 当前选中的空白"新会话"行在被选中期间渲染在其记账第一位,这是纯渲染期置顶,从不改动持久顺序;其余行保持手动排序或最近更新位置,点开非空白会话不会因导航而重排。 - 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、两种模式下把当前空白"新会话"行置顶到记账最前、点开非空白会话不改变位置、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、两种模式下把当前空白"新会话"行置顶到记账最前、点开非空白会话不改变位置、被置顶掩盖的拖拽被整体跳过且不写浏览器或 Host 顺序、空白转正后置顶释放、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 2a1ec9255b..3b656ac06b 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -95,6 +95,32 @@ function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readon return ordered } +/** + * Render-time pin: the blank Session the user is currently in (the New + * Session being created) renders first in its account in both order modes. + * Opening or reusing a blank never advances its `updatedAt` (the Host derives + * it from `createdAt` and `lastPromptAt`), so without the pin a reused blank + * would keep its old creation-time position. The pin applies only to this + * derived render order — it is never written back to the persisted account + * order, so the stored position returns as soon as the Session stops being + * the current blank. + * @param order - account order before pinning. + * @param list - sessions list snapshot (`current` and `byId` decide the pin). + * @returns `order` with the current blank moved to the front, otherwise `order` unchanged. + */ +function pinCurrentBlank(order: readonly T[], list: SessionListState): T[] { + const current = list.current + if (current === undefined || list.byId[current]?.blank !== true) return [...order] + const key = current as unknown as T + if (!order.includes(key)) return [...order] + return [key, ...order.filter(id => id !== key)] +} + +/** True when two orders hold the same ids at the same indexes. */ +function sameOrder(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((id, index) => id === b[index]) +} + /** 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 @@ -103,13 +129,7 @@ function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListStat return a < b ? -1 : 1 } -/** - * Reconcile one editable order account and apply its activity-promotion - * policy. The blank session the user is currently in (the New Session being - * created) is pinned first in every mode: opening or reusing a blank never - * advances its `updatedAt`, so without the pin the row would keep its old - * creation-time position. - */ +/** Reconcile one editable order account and apply its activity-promotion policy. */ function nextSessionOrderAccount({ sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency, }: { @@ -136,12 +156,6 @@ function nextSessionOrderAccount({ order = [...promoted, ...order.filter(id => !promotedIds.has(id))] } } - // The New Session being created stays first in its account while it is the - // current selection, in both order modes. - const current = list.current - if (current !== undefined && list.byId[current]?.blank === true && sessionIds.includes(current)) { - order = [current, ...order.filter(id => id !== current)] - } const updatedAt: Record = {} for (const id of sessionIds) { const session = list.byId[id] @@ -324,20 +338,20 @@ function SessionTree({ const orderedWorkspaces = useMemo(() => { return workspaces.map((workspace) => { const stored = sessionOrderByAccount[workspace.workspaceId as string] - const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored) + const sessionIds = pinCurrentBlank(reconciledSessionOrder(workspace.sessionIds, stored), list) return { ...workspace, sessionIds } }) - }, [sessionOrderByAccount, workspaces]) + }, [list, sessionOrderByAccount, workspaces]) const orderedUngroupedSessionIds = useMemo( - () => reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]), - [sessionOrderByAccount, ungroupedSessionIds], + () => pinCurrentBlank(reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]), list), + [sessionOrderByAccount, ungroupedSessionIds, list], ) const groups = useMemo( () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedGroups, ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined ? {} - : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }), + : { ungroupedOrder: pinCurrentBlank(sessionOrderByAccount[UNGROUPED_KEY], list) }), }), [list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount], ) @@ -364,6 +378,11 @@ function SessionTree({ const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId) const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + // The render-time pin cancels drags whose committed order would render + // unchanged (dragging the pinned New Session row, or parking another row + // into the pinned slot). Skip the whole commit so neither the browser + // account nor the Host account records a move the user never saw. + if (sameOrder(accountSessionIds, pinCurrentBlank(nextOrder, list))) 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) => { @@ -603,12 +622,12 @@ function FlatList({ }, [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]) + return pinCurrentBlank(reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]), list) .flatMap((id) => { const row = byId.get(id) return row === undefined ? [] : [row] }) - }, [baseRows, sessionOrderByAccount, sessionIds]) + }, [baseRows, list, sessionOrderByAccount, sessionIds]) const [drag, setDrag] = useState(null) const dropCommitted = useRef(false) useNativeDragAcceptance(drag !== null) @@ -626,6 +645,9 @@ 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) + // The render-time pin masks drags of the pinned New Session row, which + // would render unchanged; skip the commit entirely. + if (sameOrder(rows.map(row => row.id), pinCurrentBlank(nextOrder, list))) return setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string)) } const now = Date.now() diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index e9511d5b6b..30f8bc91bf 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -96,7 +96,7 @@ function rerender(b: ReturnType, overrides: Partial): (string | null)[] { +function rowsOf(): (string | null)[] { return screen.getAllByRole('treeitem').map(row => row.textContent) } @@ -488,7 +488,7 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByText('alpha')) await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) // The stale blank is hidden while it is not the current session. - expect(rowsOf(b)).toEqual([ + expect(rowsOf()).toEqual([ expect.stringContaining('alpha'), expect.stringContaining('mid'), expect.stringContaining('old'), @@ -503,7 +503,7 @@ describe('WorkspaceBrowser', () => { ], { current: sid('blank') })), }) await waitFor(() => { - expect(rowsOf(b)).toEqual([ + expect(rowsOf()).toEqual([ expect.stringContaining('alpha'), expect.stringContaining('新会话'), expect.stringContaining('mid'), @@ -534,7 +534,7 @@ describe('WorkspaceBrowser', () => { ], { current: sid('old') })), }) await waitFor(() => { - expect(rowsOf(b)).toEqual([ + expect(rowsOf()).toEqual([ expect.stringContaining('alpha'), expect.stringContaining('mid'), expect.stringContaining('old'), @@ -557,7 +557,7 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) await waitFor(() => { - expect(rowsOf(b)).toEqual([ + expect(rowsOf()).toEqual([ expect.stringContaining('新会话'), expect.stringContaining('mid'), expect.stringContaining('old'), @@ -582,7 +582,7 @@ describe('WorkspaceBrowser', () => { // Manual order follows the Workspace account [old, blank, mid]; the // current blank is pinned first, the rest keeps the account order. await waitFor(() => { - expect(rowsOf(b)).toEqual([ + expect(rowsOf()).toEqual([ expect.stringContaining('alpha'), expect.stringContaining('新会话'), expect.stringContaining('old'), @@ -591,6 +591,92 @@ describe('WorkspaceBrowser', () => { }) }) + it('skips drags masked by the pinned New Session row without reordering or writing Host', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([ + summary('one', 3), + summary('blank', 2, { blank: true }), + summary('three', 1), + ], { current: sid('blank') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])), + insertSessionBefore, + }) + // The current session's group auto-expands, exposing the pinned rows. + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) + }) + await waitFor(() => { + expect(rowsOf()).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('新会话'), + expect.stringContaining('one'), + expect.stringContaining('three'), + ]) + }) + const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + three.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + // Dragging the pinned New Session row to the end would render unchanged + // (the pin returns it to the front): the whole commit is skipped. + fireEvent.dragStart(blankRow, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) + expect(insertSessionBefore).not.toHaveBeenCalled() + // Parking the row directly below the pinned blank above it is masked the + // same way: no browser reorder and no Host write. + blankRow.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(blankRow, 'drop', 105) + expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) + expect(insertSessionBefore).not.toHaveBeenCalled() + }) + + it('releases the pin when the current New Session stops being blank', async () => { + const b = mount({ + useSessions: hook(sessionState([ + summary('one', 3), + summary('blank', 2, { blank: true }), + summary('three', 1), + ], { current: sid('blank') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])), + }) + // The current session's group auto-expands, exposing the pinned rows. + await waitFor(() => { + expect(rowsOf()).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('新会话'), + expect.stringContaining('one'), + expect.stringContaining('three'), + ]) + }) + // The pin is a render-time effect only: the persisted account order + // keeps the blank in its creation-time slot throughout. + expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) + // Sending the first prompt turns the blank into a real session; the pin + // is released and the row returns to its stored slot. + rerender(b, { + useSessions: hook(sessionState([ + summary('one', 3), + summary('blank', 2, { blank: false }), + summary('three', 1), + ], { current: sid('blank') })), + }) + await waitFor(() => { + expect(rowsOf()).toEqual([ + expect.stringContaining('alpha'), + expect.stringContaining('one'), + expect.stringContaining('blank'), + expect.stringContaining('three'), + ]) + }) + }) + it('shows local metadata matches immediately, then clears back to the grouped tree', async () => { vi.useFakeTimers() try { From b3b6407a2cc70d11fbc1cf8d38b4cf2f582a0b68 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 14:21:22 +0800 Subject: [PATCH 4/5] fix(ui-workspace): promote new sessions only once --- ...kspace-sidebar-order-and-folding.i18n.yaml | 4 +- ...-11-workspace-sidebar-order-and-folding.md | 6 +- ...-workspace-sidebar-order-and-folding.zh.md | 6 +- .../src/client/WorkspaceBrowser.tsx | 73 +++--- .../tests/workspace-browser.client.spec.tsx | 223 ++++-------------- 5 files changed, 80 insertions(+), 232 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index 610f1702d6..3a2a837ce9 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.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: c09ec72be443be7fb22c42df98e42a73d83d468b -2026-08-11-workspace-sidebar-order-and-folding.zh.md: 481066d438722568728587ad0764b193eb56650e +2026-08-11-workspace-sidebar-order-and-folding.md: ad079cfc71d6efff7679ce3b8512167bce95e6c8 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: fdc23fbc3a745b030c296d91257adb505b282471 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index c09ec72be4..ad079cfc71 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -24,7 +24,7 @@ Each Workspace persists one browser-local open state: closed means zero Session 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. -The blank Session that is currently selected — the New Session being created — renders first in its account in both Manual and Last updated. Opening or reusing a blank never advances its `updatedAt` (the Host derives it from `createdAt` and `lastPromptAt`), so without the pin a reused blank would keep its old creation-time position. The pin is applied when deriving the render order and is never written back to the persisted per-account order: deselecting the blank or sending its first prompt restores its stored slot, and a drag the pin would mask (dragging the pinned blank itself, or parking another Session into the pinned slot) is skipped entirely, including the Host account write. +When New Session creation selects a blank Session, the browser promotes it once in both its grouped account and the flat-list account. This explicit creation promotion does not advance `updatedAt`; later drag ordering treats the blank like any other Session, and the first prompt does not undo a Manual-mode drag. ### Drag and compact chrome @@ -50,10 +50,10 @@ Search is a header action while collapsed and expands across the title and trail - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. -- The current blank New Session row renders first in its account while it is selected, as a render-time pin that never touches the persisted order; every other row keeps its Manual or Last-updated position, and a non-blank current Session is never reordered by navigation. +- A newly selected blank New Session row enters grouped and flat orders first once, then follows the same drag and activity rules as every other Session. - Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). ## 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, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, pinning the current blank New Session row to its account front in both modes, non-blank navigation leaving positions untouched, masked drags of the pinned row being skipped without a browser reorder or Host write, the pin releasing when the blank turns real, 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. +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, transient expansion reset, 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. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index 481066d438..fdc23fbc3a 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -24,7 +24,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 -当前选中的空白 Session(正在新建的"新会话")在**手动排序**和**最近更新**两种模式下都渲染在其记账第一位。打开或复用空白不会推进其 `updatedAt`(Host 从 `createdAt` 与 `lastPromptAt` 推导),若不置顶,复用的空白会停留在创建时的旧位置。置顶只作用于派生渲染顺序,绝不写回持久化的每记账顺序:取消选中空白或发出首条提示词后即恢复其存储位置;会被置顶掩盖的拖拽(拖动被置顶的空白本身,或把其他 Session 放入被置顶的槽位)会被整体跳过,包括 Host 记账写入。 +创建“新会话”并选中空白 Session 时,浏览器会在其分组记账和单列表记账中各置顶一次。这次明确的创建置顶不会推进 `updatedAt`;后续拖拽把空白 Session 当作普通 Session,首条提示词落地也不会撤销手动模式下的拖拽。 ### 拖拽与紧凑界面 @@ -50,10 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 -- 当前选中的空白"新会话"行在被选中期间渲染在其记账第一位,这是纯渲染期置顶,从不改动持久顺序;其余行保持手动排序或最近更新位置,点开非空白会话不会因导航而重排。 +- 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次,之后遵循与其他 Session 相同的拖拽和活动规则。 - 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、两种模式下把当前空白"新会话"行置顶到记账最前、点开非空白会话不改变位置、被置顶掩盖的拖拽被整体跳过且不写浏览器或 Host 顺序、空白转正后置顶释放、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 3b656ac06b..08f22ed400 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -95,32 +95,6 @@ function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readon return ordered } -/** - * Render-time pin: the blank Session the user is currently in (the New - * Session being created) renders first in its account in both order modes. - * Opening or reusing a blank never advances its `updatedAt` (the Host derives - * it from `createdAt` and `lastPromptAt`), so without the pin a reused blank - * would keep its old creation-time position. The pin applies only to this - * derived render order — it is never written back to the persisted account - * order, so the stored position returns as soon as the Session stops being - * the current blank. - * @param order - account order before pinning. - * @param list - sessions list snapshot (`current` and `byId` decide the pin). - * @returns `order` with the current blank moved to the front, otherwise `order` unchanged. - */ -function pinCurrentBlank(order: readonly T[], list: SessionListState): T[] { - const current = list.current - if (current === undefined || list.byId[current]?.blank !== true) return [...order] - const key = current as unknown as T - if (!order.includes(key)) return [...order] - return [key, ...order.filter(id => id !== key)] -} - -/** True when two orders hold the same ids at the same indexes. */ -function sameOrder(a: readonly string[], b: readonly string[]): boolean { - return a.length === b.length && a.every((id, index) => id === b[index]) -} - /** 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 @@ -338,20 +312,20 @@ function SessionTree({ const orderedWorkspaces = useMemo(() => { return workspaces.map((workspace) => { const stored = sessionOrderByAccount[workspace.workspaceId as string] - const sessionIds = pinCurrentBlank(reconciledSessionOrder(workspace.sessionIds, stored), list) + const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored) return { ...workspace, sessionIds } }) - }, [list, sessionOrderByAccount, workspaces]) + }, [sessionOrderByAccount, workspaces]) const orderedUngroupedSessionIds = useMemo( - () => pinCurrentBlank(reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]), list), - [sessionOrderByAccount, ungroupedSessionIds, list], + () => reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]), + [sessionOrderByAccount, ungroupedSessionIds], ) const groups = useMemo( () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedGroups, ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined ? {} - : { ungroupedOrder: pinCurrentBlank(sessionOrderByAccount[UNGROUPED_KEY], list) }), + : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }), }), [list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount], ) @@ -378,11 +352,6 @@ function SessionTree({ const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId) const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) - // The render-time pin cancels drags whose committed order would render - // unchanged (dragging the pinned New Session row, or parking another row - // into the pinned slot). Skip the whole commit so neither the browser - // account nor the Host account records a move the user never saw. - if (sameOrder(accountSessionIds, pinCurrentBlank(nextOrder, list))) 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) => { @@ -622,12 +591,12 @@ function FlatList({ }, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, sessionIds, syncSessionOrderAccount]) const rows = useMemo(() => { const byId = new Map(baseRows.map(row => [row.id, row])) - return pinCurrentBlank(reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]), list) + return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) .flatMap((id) => { const row = byId.get(id) return row === undefined ? [] : [row] }) - }, [baseRows, list, sessionOrderByAccount, sessionIds]) + }, [baseRows, sessionOrderByAccount, sessionIds]) const [drag, setDrag] = useState(null) const dropCommitted = useRef(false) useNativeDragAcceptance(drag !== null) @@ -645,9 +614,6 @@ 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) - // The render-time pin masks drags of the pinned New Session row, which - // would render unchanged; skip the commit entirely. - if (sameOrder(rows.map(row => row.id), pinCurrentBlank(nextOrder, list))) return setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string)) } const now = Date.now() @@ -811,6 +777,31 @@ export function WorkspaceBrowser({ 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 + ? undefined + : (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId)) + ?.workspaceId as string | undefined) ?? UNGROUPED_KEY + const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined) + useEffect(() => { + if (currentBlankSessionId === undefined || currentBlankAccount === undefined) { + promotedBlank.current = undefined + return + } + if (promotedBlank.current?.sessionId === currentBlankSessionId + && promotedBlank.current.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), + ]) + } + }, [actions.setSessionOrder, currentBlankAccount, currentBlankSessionId, sessionOrderByAccount]) useEffect(() => { if (workspacePhase !== 'ready') return actions.retainAccountKeys([ diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 30f8bc91bf..820d2bd66b 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -95,11 +95,6 @@ function rerender(b: ReturnType, overrides: Partial) } -/** Every tree row's text, group headers included, in render order. */ -function rowsOf(): (string | null)[] { - return screen.getAllByRole('treeitem').map(row => row.textContent) -} - describe('WorkspaceBrowser', () => { it('workspace hover card shows a POSIX home descendant as ~', () => { vi.useFakeTimers() @@ -473,207 +468,69 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('新会话')).toBeNull() }) - it('pins the current blank New Session row first in Last-updated mode (workspace view)', async () => { + 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([ - summary('old', 100), - summary('blank', 150, { blank: true }), - summary('mid', 200), - ])), + useSessions: hook(sessionState(items)), useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), - }) - fireEvent.click(screen.getByRole('button', { name: '视图选项' })) - fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) - await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) - fireEvent.click(screen.getByText('alpha')) - await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) - // The stale blank is hidden while it is not the current session. - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('mid'), - expect.stringContaining('old'), - ]) - // New Session reuses the blank: it becomes current and jumps to the top, - // even though its updatedAt (creation time) is older than mid's. - rerender(b, { - useSessions: hook(sessionState([ - summary('old', 100), - summary('blank', 150, { blank: true }), - summary('mid', 200), - ], { current: sid('blank') })), + startSession, }) await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('新会话'), - expect.stringContaining('mid'), - expect.stringContaining('old'), - ]) + 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 pin a non-blank current session in Last-updated mode', async () => { - const b = mount({ - useSessions: hook(sessionState([ - summary('old', 100), - summary('mid', 200), - ])), - useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'mid'])])), - }) - fireEvent.click(screen.getByRole('button', { name: '视图选项' })) - fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) - await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) - fireEvent.click(screen.getByText('alpha')) - await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) - // Opening an ordinary session is navigation, not a New Session gesture: - // the selected row stays at its recency position. - rerender(b, { - useSessions: hook(sessionState([ - summary('old', 100), - summary('mid', 200), - ], { current: sid('old') })), - }) - await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('mid'), - expect.stringContaining('old'), - ]) - }) - }) - - it('pins the current blank first in the flat list too', async () => { - const b = mount({ - useSessions: hook(sessionState([ - summary('old', 100), - summary('blank', 150, { blank: true }), - summary('mid', 200), - ], { current: sid('blank') })), - useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), - }) - fireEvent.click(screen.getByRole('button', { name: '视图选项' })) - fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) - fireEvent.click(screen.getByRole('button', { name: '视图选项' })) - fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) - await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') }) - await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('新会话'), - expect.stringContaining('mid'), - expect.stringContaining('old'), - ]) - }) - }) - - it('pins the current blank first in manual mode too', async () => { - const b = mount({ - useSessions: hook(sessionState([ - summary('old', 100), - summary('blank', 150, { blank: true }), - summary('mid', 200), - ], { current: sid('blank') })), - useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), - }) - expect(b.store.getSnapshot().orderBy).toBe('manual') - // The current session's group auto-expands; the New Session being - // created renders first even though the manual account order holds it - // in its creation-time slot. - await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) }) - // Manual order follows the Workspace account [old, blank, mid]; the - // current blank is pinned first, the rest keeps the account order. - await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('新会话'), - expect.stringContaining('old'), - expect.stringContaining('mid'), - ]) - }) - }) - - it('skips drags masked by the pinned New Session row without reordering or writing Host', async () => { + it('does not repeat blank promotion after a manual drag or the first prompt', async () => { const insertSessionBefore = vi.fn(async () => {}) const b = mount({ useSessions: hook(sessionState([ - summary('one', 3), - summary('blank', 2, { blank: true }), - summary('three', 1), + summary('old', 100), + summary('blank', 150, { blank: true }), + summary('mid', 200), ], { current: sid('blank') })), - useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])), insertSessionBefore, }) - // The current session's group auto-expands, exposing the pinned rows. await waitFor(() => { - expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid']) }) - await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('新会话'), - expect.stringContaining('one'), - expect.stringContaining('three'), - ]) - }) - const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement - const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement - const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement - three.getBoundingClientRect = () => ({ + const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement + const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement + mid.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - // Dragging the pinned New Session row to the end would render unchanged - // (the pin returns it to the front): the whole commit is skipped. - fireEvent.dragStart(blankRow, { dataTransfer: dragData() }) - fireDrag(three, 'drop', 180) - expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) - expect(insertSessionBefore).not.toHaveBeenCalled() - // Parking the row directly below the pinned blank above it is masked the - // same way: no browser reorder and no Host write. - blankRow.getBoundingClientRect = () => ({ - top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), - }) - fireEvent.dragStart(one, { dataTransfer: dragData() }) - fireDrag(blankRow, 'drop', 105) - expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) - expect(insertSessionBefore).not.toHaveBeenCalled() - }) + 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) - it('releases the pin when the current New Session stops being blank', async () => { - const b = mount({ - useSessions: hook(sessionState([ - summary('one', 3), - summary('blank', 2, { blank: true }), - summary('three', 1), - ], { current: sid('blank') })), - useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])), - }) - // The current session's group auto-expands, exposing the pinned rows. - await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('新会话'), - expect.stringContaining('one'), - expect.stringContaining('three'), - ]) - }) - // The pin is a render-time effect only: the persisted account order - // keeps the blank in its creation-time slot throughout. - expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three']) - // Sending the first prompt turns the blank into a real session; the pin - // is released and the row returns to its stored slot. rerender(b, { useSessions: hook(sessionState([ - summary('one', 3), - summary('blank', 2, { blank: false }), - summary('three', 1), + summary('old', 100), + summary('blank', 150), + summary('mid', 200), ], { current: sid('blank') })), }) await waitFor(() => { - expect(rowsOf()).toEqual([ - expect.stringContaining('alpha'), - expect.stringContaining('one'), - expect.stringContaining('blank'), - expect.stringContaining('three'), - ]) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank']) }) }) From 9a2dc3327d27f83cee596e149440f833aaa94060 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 14:40:09 +0800 Subject: [PATCH 5/5] test(web): align blank-session activity snapshot --- .../sidebar-subagent-activity/owner-running.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md index 62671cbb8b..7e1864ff58 100644 --- a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md +++ b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md @@ -2,5 +2,5 @@ - treeitem "workspace" [expanded]: - img - text: workspace - - treeitem "New Session" [selected] - treeitem "1 subagent running Delegate a background job. now" + - treeitem "New Session" [selected]