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 c2bc946589..dcb1144635 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: ad079cfc71d6efff7679ce3b8512167bce95e6c8 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: 1c3c4345f59a7e4ed1d801bdb7ffe0937e4733b4 +2026-08-11-workspace-sidebar-order-and-folding.md: 4582631da79c946a2d3a713bc0d3a085ebed4cd0 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 6dfd443599b6c6696bbe12c19ebcde411d86a517 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 ad079cfc71..4582631da7 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 @@ -20,15 +20,15 @@ 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. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. 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. 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. -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. +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`. While the Session remains blank, grouped folding does not charge it against the five non-blank rows; the first prompt returns it to the ordinary quota without undoing a Manual-mode drag. ### 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. +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. A collapsed Session drag resolves its insertion boundary from rendered rows, places the source before any hidden account members at that boundary, and rejects a result that would hide the source. 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. Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query; while the rail search gesture is still in flight the outside-click listener stays unmounted ([rail-search self-dismissal](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)). Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. @@ -40,7 +40,7 @@ Search is a header action while collapsed and expands across the title and trail **Always show every Session in an open Workspace.** One large Workspace would continue to crowd out the rest, and remembering only the whole-group open state would not bound its height. -**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the zero-or-five state represents a stable navigation preference; revealing the remainder is a local inspection. +**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the bounded folded state represents a stable navigation preference; revealing the remainder is a local inspection. **Use numeric drop indices or header-only hit testing.** Indices drift when rows change during a drag, while header midpoints disagree with the visible boundary when a Workspace is expanded. Anchor ids and full-section geometry remain stable under both conditions. @@ -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. -- 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. +- A newly selected blank New Session row enters grouped and flat orders first once. Grouped folding renders it in addition to five non-blank rows until its first prompt, then applies the ordinary quota. +- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture; the selected blank New Session may add one provisional row. Closing the Workspace resets only the 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 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. +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, 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. 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 1c3c4345f5..6dfd443599 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 @@ -20,15 +20,15 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins ### Session 折叠与视图顺序 -每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 -创建“新会话”并选中空白 Session 时,浏览器会在其分组记账和单列表记账中各置顶一次。这次明确的创建置顶不会推进 `updatedAt`;后续拖拽把空白 Session 当作普通 Session,首条提示词落地也不会撤销手动模式下的拖拽。 +创建“新会话”并选中空白 Session 时,浏览器会在其分组记账和单列表记账中各置顶一次。这次明确的创建置顶不会推进 `updatedAt`。Session 保持空白期间,分组折叠不会让它占用五条非空白行配额;首条提示词落地后,该行恢复普通配额,同时不会撤销手动模式下的拖拽。 ### 拖拽与紧凑界面 -Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 +Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。折叠状态的 Session 拖拽按渲染行确定插入边界,把来源行放在该边界处所有隐藏记账成员之前,并拒绝会隐藏来源行的结果。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留;轨道搜索手势仍在进行期间,外部点击监听器保持未挂载([轨道搜索自我收起](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md))。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 @@ -40,7 +40,7 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 **打开 Workspace 时始终显示全部 Session。** 大型 Workspace 仍会挤占其他分组;只记忆整个分组的打开状态无法限制其高度。 -**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有零条或五条状态属于稳定导航偏好;显示剩余项只是一次本地查看。 +**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有有界折叠状态属于稳定导航偏好;显示剩余项只是一次本地查看。 **使用数字下标或只按组头命中拖拽。** 拖拽期间行发生变化会使下标漂移;Workspace 展开时,组头中点与可见边界不一致。锚点 id 与完整区段几何在两种情况下都保持稳定。 @@ -50,10 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 -- 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次,之后遵循与其他 Session 相同的拖拽和活动规则。 -- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 +- 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次。分组折叠会在五条非空白行之外额外渲染该行,直至首条提示词落地后恢复普通配额。 +- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组只重置这项临时手势。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.zh.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 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md new file mode 100644 index 0000000000..95f2becd57 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -0,0 +1,15 @@ +- tree "Sessions": + - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: + - img + - text: {{workspace}} + - button "Workspace actions for {{workspace}}": + - img + - button "New session in {{workspace}}": + - img + - treeitem "New Session" [selected] + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - button "Show 1 more sessions" diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts new file mode 100644 index 0000000000..1b91aaa57e --- /dev/null +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -0,0 +1,92 @@ +/** Blank New Session folding through the shipped Web composition. */ + +import { readFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-folding', import.meta.url)) +const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md') +const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const EXISTING_SESSION_COUNT = 6 + +describe('web e2e: blank New Session folding quota', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const fixture = await readFile(SEED, 'utf8') + const sessionIds = [] + for (let index = 1; index <= EXISTING_SESSION_COUNT; index += 1) { + sessionIds.push(await seedSession( + scaffold, + fixture, + `workspace-new-session-folding-${String(index).padStart(2, '0')}`, + )) + } + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + for (const sessionId of sessionIds) await workspace.attachSession(sessionId) + + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const workspaceTitle = basename(scaffold.workspaceCwd) + const workspaceRow = page.getByText(workspaceTitle, { exact: true }).first() + .locator('xpath=ancestor::*[@role="treeitem"][1]') + await workspaceRow.waitFor({ timeout: 15_000 }) + if (await workspaceRow.getAttribute('aria-expanded') !== 'true') await workspaceRow.click() + await workspaceRow.hover() + await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click() + await page.getByRole('tree', { name: 'Sessions' }) + .getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('keeps five established sessions beside the provisional row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-new-session-folding')) + const sidebar = page.getByRole('tree', { name: 'Sessions' }) + await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7) + expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1) + expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6) + const showMore = sidebar.getByRole('button', { name: 'Show 1 more sessions' }) + await showMore.waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden( + SIDEBAR_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + + await showMore.click() + await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(8) + expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(7) + await sidebar.getByRole('button', { name: 'Show less' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c14855169f..a104388946 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -48,6 +48,7 @@ "tests/onboarding-deepseek-config.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", + "tests/workspace-new-session-folding.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/hmr-live.e2e.ts", diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0fe162e86a..6292a5c3c0 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 998ec043535a051fa22993b61663b4401bbb5ebf -README.zh.md: 93d117996e92cf3ef09cdfdedbb2282731606bb0 +README.md: bf6a4ba48c1c6991f98c633ae0b7aa1d61305c22 +README.zh.md: e85b7d25e56e62ab9e9d392797c165da12ee7a35 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 998ec04353..bf6a4ba48c 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. +The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five non-blank Sessions by default, keeps the selected blank **New Session** as one provisional extra row until its first prompt, offers a transient **Show more** control for the hidden remainder, and returns to that folded projection after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. A collapsed group resolves drag boundaries from its rendered rows and places the source ahead of intervening hidden rows, so the dragged row cannot disappear into the remainder. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming — except while the rail search gesture is still in flight (until focus lands in the input after the column slide), so the expanding click cannot dismiss the search it opened — while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 93d117996e..e85b7d25e5 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条非空白 Session,当前选中的空白**新会话**在首条提示词落地前作为一条临时额外行保留,其余隐藏条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为该折叠投影。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。折叠分组按当前渲染行确定拖拽边界,并把来源行放在中间隐藏行之前,因此拖动行不会掉入隐藏的剩余项。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询——但轨道搜索手势仍在进行期间(直至列滑动结束、焦点落入输入框)除外,这样触发展开的那次点击不会收起它刚打开的搜索——而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index c95ad179e9..2b2aba93ce 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -40,6 +40,21 @@ const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 +/** Fold one Workspace without charging its provisional New Session against the ordinary-row limit. */ +function collapsedSessionRows(sessions: readonly SessionNode[]): { + rows: readonly SessionNode[] + hiddenCount: number +} { + let ordinaryCount = 0 + const rows = sessions.filter((session) => { + if (session.blank) return true + if (ordinaryCount >= COLLAPSED_SESSION_LIMIT) return false + ordinaryCount += 1 + return true + }) + return { rows, hiddenCount: sessions.length - rows.length } +} + /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { const withoutNul = value.replaceAll('\0', '') @@ -339,22 +354,47 @@ function SessionTree({ setDrag(null) const group = groups.find(candidate => candidate.key === activeDrag.accountKey) if (group === undefined) return - const targetIndex = group.sessions.findIndex(session => session.id === over.id) + const sessionsExpanded = expandedSessionGroups.includes(group.key) + const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows + const targetIndex = renderedSessions.findIndex(session => session.id === over.id) if (targetIndex === -1) return - const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id - if (anchor === activeDrag.sessionId) return - const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId) - const anchorIndex = anchor === undefined - ? group.sessions.length - : group.sessions.findIndex(session => session.id === anchor) - if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + const sourceIndex = renderedSessions.findIndex(session => session.id === activeDrag.sessionId) + if (over.id === activeDrag.sessionId) return + const withoutSource = renderedSessions.filter(session => session.id !== activeDrag.sessionId) + const targetWithoutSourceIndex = withoutSource.findIndex(session => session.id === over.id) + if (targetWithoutSourceIndex === -1) return + const visibleInsertAt = over.half === 'before' ? targetWithoutSourceIndex : targetWithoutSourceIndex + 1 + if (sourceIndex !== -1 && visibleInsertAt === sourceIndex) return const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY ? orderedUngroupedSessionIds : orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds if (accountSessionIds === undefined) return const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId) + let anchor: SessionId | undefined + if (sessionsExpanded) { + anchor = over.half === 'before' ? over.id : renderedSessions[targetIndex + 1]?.id + } else { + // A collapsed group may render the blank row after hidden ordinary rows. + // Place the source at the visible boundary before those hidden account members. + const previousVisible = withoutSource[visibleInsertAt - 1]?.id + if (previousVisible === undefined) { + anchor = nextOrder[0] + } else { + const previousIndex = nextOrder.indexOf(previousVisible) + if (previousIndex === -1) return + anchor = nextOrder[previousIndex + 1] + } + } const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + if (!sessionsExpanded && sourceIndex !== -1) { + const nodes = new Map(group.sessions.map(node => [node.id, node])) + const nextGroup = nextOrder.flatMap((id) => { + const node = nodes.get(id) + return node === undefined ? [] : [node] + }) + if (!collapsedSessionRows(nextGroup).rows.some(node => node.id === activeDrag.sessionId)) return + } setSessionOrder(activeDrag.accountKey, nextOrder.map(id => id as string)) if (orderBy === 'updated' || activeDrag.accountKey === UNGROUPED_KEY) return insertSessionBefore(activeDrag.accountKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { @@ -398,6 +438,8 @@ function SessionTree({ )} {groups.map((group) => { const workspaceId = group.workspaceId + const collapsed = collapsedSessionRows(group.sessions) + const sessionsExpanded = expandedSessionGroups.includes(group.key) const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId ? workspaceDrag.over.half : null @@ -483,9 +525,9 @@ function SessionTree({ }, }} /> - {(expandedSessionGroups.includes(group.key) + {(sessionsExpanded ? group.sessions - : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) + : collapsed.rows ).map((node) => { // Session drag never leaves its group. Ungrouped writes only the // browser-local account; real Workspaces may also write Host order. @@ -527,16 +569,16 @@ function SessionTree({ /> ) })} - {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + {collapsed.hiddenCount > 0 && ( )} 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 b15650e888..a623c3334d 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -277,6 +277,79 @@ describe('WorkspaceBrowser', () => { expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() }) + it('keeps the blank New Session outside the five-row folding quota', () => { + const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index)) + const blank = summary('blank', 7, { blank: true }) + const b = mount({ + useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])), + }) + expect(screen.getByText('新会话')).toBeTruthy() + for (const item of ordinary.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' })) + expect(screen.getByText('session-6')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expect(screen.queryByText('session-6')).toBeNull() + + rerender(b, { + useSessions: hook(sessionState([{ ...blank, blank: false }, ...ordinary], { current: blank.id })), + }) + expect(screen.getByText('blank')).toBeTruthy() + expect(screen.queryByText('session-5')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + + it('anchors collapsed drags before hidden rows so the source stays visible', async () => { + const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index)) + const blank = summary('blank', 7, { blank: true }) + const insertSessionBefore = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])), + insertSessionBefore, + }) + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount.alpha) + .toEqual(['blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6']) + }) + + fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' })) + const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement + const session6 = screen.getByText('session-6').closest('[role="treeitem"]') as HTMLElement + session6.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 200, toJSON: () => ({}), + }) + fireEvent.dragStart(blankRow, { dataTransfer: dragData() }) + fireDrag(session6, 'drop', 230) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha) + .toEqual(['session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6', 'blank']) + + insertSessionBefore.mockClear() + fireEvent.click(screen.getByRole('button', { name: '收起' })) + const collapsedBlank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement + collapsedBlank.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 200, toJSON: () => ({}), + }) + const session5 = screen.getByText('session-5').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(session5, { dataTransfer: dragData() }) + fireDrag(collapsedBlank, 'drop', 205) + expect(insertSessionBefore).not.toHaveBeenCalled() + + const session4 = screen.getByText('session-4').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(session4, { dataTransfer: dragData() }) + fireDrag(collapsedBlank, 'drop', 205) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha) + .toEqual(['session-1', 'session-2', 'session-3', 'session-5', 'session-4', 'session-6', 'blank']) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('session-4'), sid('session-6')) + expect(screen.getByText('session-4')).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + }) + it('shares one editable order across modes and promotes only while Last updated is active', async () => { const initial = sessionState([summary('one', 3), summary('two', 2)]) const b = mount({ diff --git a/tsconfig.host.json b/tsconfig.host.json index 6d3e5fc870..9afc5d3fdd 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,7 @@ "apps/web/tests/onboarding-deepseek-config.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", + "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts",