From 55a7590952b708621ae94f0dd07a34996c382a69 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:59:28 +0700 Subject: [PATCH] Sort sidebar workspaces and keep active overflow visible (#7635) Order ordinary sidebar workspaces by Created or Last prompt, preserve project and pin ordering, and keep later active or unacknowledged work visible beyond the first seven rows. Preserve reveal state through live updates and verify sorting, visibility, compatibility, and rendering behavior. --- docs/glossary.md | 1 + .../sidebar-mobile-menu-sheets.spec.ts | 1 + .../browser/sidebar-project-filter.spec.ts | 1 + .../app/e2e/browser/sidebar-reorder.spec.ts | 268 +++++++++++++++++- .../browser/sidebar-sort-old-daemon.spec.ts | 84 ++++++ .../workspace-relative-navigation.spec.ts | 20 ++ .../native-sidebar-drag-release.ios.ad | 3 +- packages/app/e2e/support/creation-fixtures.ts | 3 +- packages/app/e2e/support/helpers/creation.ts | 2 + packages/app/e2e/support/helpers/sidebar.ts | 10 + .../sidebar-workspace-list.test.tsx | 197 ++++++++++++- .../src/components/sidebar-workspace-list.tsx | 150 ++++------ .../sidebar/display-preferences/menu.tsx | 44 ++- .../sidebar/display-preferences/model.ts | 9 + .../sidebar/sidebar-group-visibility.ts | 21 ++ .../src/components/sidebar/sidebar-model.tsx | 2 +- .../sidebar/sidebar-status-list.tsx | 7 +- .../sidebar/sidebar-workspace-row.tsx | 4 +- .../sidebar/use-limited-sidebar-group.ts | 20 +- .../use-long-press-drag-interaction.ts | 4 +- ...space-shortcut-targets-subscriber.test.tsx | 3 +- .../app/src/composer/diff-stat-pill.test.tsx | 2 + .../session-workspace-upserts.test.ts | 2 + .../sidebar-workspaces-view-model.test.ts | 50 ++-- .../hooks/sidebar-workspaces-view-model.ts | 70 +++-- .../src/hooks/use-sidebar-workspaces-list.ts | 27 +- packages/app/src/i18n/resources/ar.ts | 6 + packages/app/src/i18n/resources/en.ts | 6 + packages/app/src/i18n/resources/es.ts | 6 + packages/app/src/i18n/resources/fr.ts | 6 + packages/app/src/i18n/resources/ja.ts | 6 + packages/app/src/i18n/resources/ko.ts | 6 + packages/app/src/i18n/resources/pt-BR.ts | 6 + packages/app/src/i18n/resources/ru.ts | 6 + packages/app/src/i18n/resources/zh-CN.ts | 6 + .../activity.integration.test.tsx | 2 + .../plugins/client-state/snapshots.test.ts | 2 + .../src/projects/workspace-structure.test.ts | 50 ++++ .../app/src/projects/workspace-structure.ts | 38 ++- .../src/runtime/directory-sync/index.test.ts | 9 +- .../src/runtime/replica-cache/index.test.ts | 31 ++ .../app/src/runtime/replica-cache/index.ts | 13 +- .../workspace/workspace-route-state.test.ts | 2 + .../workspace-source-of-truth.test.ts | 2 + .../src/stores/session-store-hooks/index.ts | 20 +- .../session-store-hooks/selectors.test.ts | 27 +- .../stores/session-store-hooks/selectors.ts | 30 +- packages/app/src/stores/session-store.test.ts | 2 + packages/app/src/stores/session-store.ts | 5 + .../src/stores/sidebar-order-store.test.ts | 46 +-- .../app/src/stores/sidebar-order-store.ts | 78 +---- .../app/src/stores/sidebar-view-store.test.ts | 13 + packages/app/src/stores/sidebar-view-store.ts | 13 +- .../src/utils/agent-directory-sync.test.ts | 2 + packages/app/src/utils/projects.test.ts | 2 + .../workspace-archive-navigation.test.ts | 2 + .../app/src/utils/workspace-identity.test.ts | 2 + .../src/workspace/workspace-archive.test.ts | 2 + packages/protocol/src/messages.ts | 3 + packages/server/src/server/agent/directory.ts | 24 +- packages/server/src/server/session.ts | 4 + .../src/server/session.workspaces.test.ts | 4 +- .../server/src/server/websocket-server.ts | 1 + .../src/server/workspace-directory.test.ts | 90 +++++- .../server/src/server/workspace-directory.ts | 5 + packages/website/playwright.config.ts | 4 +- 66 files changed, 1225 insertions(+), 362 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts create mode 100644 packages/app/src/components/sidebar/sidebar-group-visibility.ts diff --git a/docs/glossary.md b/docs/glossary.md index 644c109d44..d2c871f74b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time. - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 8b06e14f67..92e4d7a095 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts index a2f8d14348..e406ba5a3f 100644 --- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts @@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => { await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index 6129d37a63..f6bf4d3f1b 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -1,9 +1,10 @@ -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; async function rowTestIds(rows: Locator) { @@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond( await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); } -test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({ +async function dragWithoutReordering(rows: Locator): Promise { + const before = await rowTestIds(rows); + const source = await visibleBoundingBox(rows.nth(0)); + const target = await visibleBoundingBox(rows.nth(1)); + const page = rows.page(); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); + await page.mouse.up(); + await expect.poll(() => rowTestIds(rows)).toEqual(before); +} + +test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({ page, }) => { const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); @@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; - await quickDragFirstRowAfterSecond( - page.locator( - `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, - ), - pressWorkspaceRow, + const ordinaryRows = page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, ); + await expect + .poll(() => rowTestIds(ordinaryRows)) + .toEqual([secondWorkspaceTestId, firstWorkspaceTestId]); + await dragWithoutReordering(ordinaryRows); await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true); await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true); @@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra await secondProject.cleanup(); } }); + +async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await page.getByRole("menuitem", { name, exact: true }).click(); +} + +async function reconnectSidebar(page: Page): Promise { + await page.reload(); + await waitForSidebarHydration(page); +} + +async function expectNoWorkspaceSortChoice(page: Page): Promise { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} + +async function promptWorkspace(seed: Awaited>, agentId: string) { + await seed.client.sendAgentMessage(agentId, "sidebar ordering check"); + expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle"); +} + +test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({ + page, +}) => { + test.setTimeout(120_000); + const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" }); + try { + const newer = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "Newer workspace", + }); + if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed"); + const agent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Ordering agent", + }); + const newerAgent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: newer.workspace.id, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Newer agent", + }); + await gotoAppShell(page); + await waitForSidebarHydration(page); + const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`; + const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`; + const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectWorkspaceSort(page, "Last prompt"); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await promptWorkspace(seed, newerAgent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await seed.client.archiveAgent(agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectSidebarStatusGrouping(page); + await expectNoWorkspaceSortChoice(page); + } finally { + await seed.cleanup(); + } +}); + +async function seedCompactProject() { + const seed = await seedWorkspace({ + repoPrefix: "sidebar-visibility-", + title: "Oldest workspace", + }); + const ids = [seed.workspaceId]; + for (let index = 2; index <= 12; index += 1) { + const created = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: `Workspace ${index}`, + }); + if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed"); + ids.unshift(created.workspace.id); + } + const agent = await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: "Overflow agent", + }); + return { seed, ids, agent }; +} + +async function expectWorkspaceOrder(rows: Locator, ids: string[]) { + await expect + .poll(() => rowTestIds(rows)) + .toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`)); +} + +async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") { + await list.getByLabel(action, { exact: true }).click(); +} + +async function selectProjectGrouping(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Group/ }).click(); + await page.getByTestId("sidebar-grouping-project").click(); +} + +type CompactProject = Awaited>; + +function compactProjectRows(page: Page, seed: CompactProject["seed"]) { + const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`); + return { + rows: list.locator('[data-testid^="sidebar-workspace-row-"]'), + group: list.locator(".."), + project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`), + }; +} + +async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) { + const { rows } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + + await seed.client.sendAgentMessage(agent.id, "show active overflow"); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.waitForFinish(agent.id, 15_000); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectRevealSurvivesSortingAndCollapse( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows, group, project } = compactProjectRows(page, seed); + const retainedRows = await rows.elementHandles(); + await revealWorkspaces(group, "Show more"); + await expectWorkspaceOrder(rows, ids); + for (const [index, retained] of retainedRows.entries()) { + expect( + await rows.nth(index).evaluate((current, previous) => current === previous, retained), + ).toBe(true); + } + await project.click(); + await expect(rows).toHaveCount(0); + await project.click(); + await expectWorkspaceOrder(rows, ids); + await seed.client.sendAgentMessage(agent.id, "expanded overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids); + await selectWorkspaceSort(page, "Last prompt"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]); + const revealedRow = await rows.nth(8).elementHandle(); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]); + await selectWorkspaceSort(page, "Created"); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectStatusBucketsRetainActiveOverflow( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows } = compactProjectRows(page, seed); + await selectSidebarStatusGrouping(page); + const done = page.getByTestId("sidebar-status-group-rows-done"); + const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]'); + await expect(doneRows).toHaveCount(7); + await revealWorkspaces(done, "Show more"); + await expect(doneRows).toHaveCount(12); + await seed.client.sendAgentMessage(agent.id, "status overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await expect( + page + .getByTestId("sidebar-status-group-rows-attention") + .getByText("Oldest workspace", { exact: true }), + ).toBeVisible(); + await expect(doneRows).toHaveCount(11); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expect(doneRows).toHaveCount(12); + await revealWorkspaces(done, "Show less"); + await expect(doneRows).toHaveCount(7); + const activeAgents: Array<{ id: string }> = []; + for (const workspaceId of ids.slice(0, 8)) { + activeAgents.push( + await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId, + title: "Active overflow agent", + }), + ); + } + await Promise.all( + activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")), + ); + await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000))); + const attention = page.getByTestId("sidebar-status-group-rows-attention"); + await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8); + await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0); + for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId); + await expect(doneRows).toHaveCount(7); + await selectProjectGrouping(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +test("compact groups retain active overflow and reveal the current ordered rows", async ({ + page, +}) => { + test.setTimeout(120_000); + const fixture = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await test.step("Active overflow appears without intervening Done rows", () => + expectActiveOverflowTransitions(page, fixture)); + await test.step("Reveal follows current sort and survives collapse", () => + expectRevealSurvivesSortingAndCollapse(page, fixture)); + await test.step("Status reveals Done rows and keeps every active row", () => + expectStatusBucketsRetainActiveOverflow(page, fixture)); + } finally { + await fixture.seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts new file mode 100644 index 0000000000..ba210131db --- /dev/null +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -0,0 +1,84 @@ +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { randomUUID } from "node:crypto"; +import { metroTest as test, expect } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; + +test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => { + test.setTimeout(180_000); + const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, { + publishedVersion: "0.8.0", + }); + try { + const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port }); + try { + const second = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath, projectId: project.projectId }, + title: "Second old workspace", + }); + expect(second.workspace).toBeTruthy(); + await openPublishedHost(page, daemon, project.workspaceId); + await openWorkspace(page, daemon.serverId, second.workspace!.id); + await expectSortingRequiresHostUpdate(page); + await selectSidebarStatusGrouping(page); + await expectStatusHasNoSorting(page); + } finally { + await project.cleanup(); + } + } finally { + await daemon.close(); + } +}); + +async function openPublishedHost( + page: Page, + daemon: { serverId: string; port: number }, + workspaceId: string, +) { + const host = buildSeededHost({ + serverId: daemon.serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript((seededHost) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem( + "sidebar-view", + JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }), + ); + }, host); + await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId)); +} + +async function openWorkspace(page: Page, serverId: string, workspaceId: string) { + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(new RegExp(workspaceId)); +} + +async function expectSortingRequiresHostUpdate(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeDisabled(); + await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function expectStatusHasNoSorting(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount( + 0, + ); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts index 4853e9daa4..909672c489 100644 --- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar"; import { expectSidebarWorkspaceSelected, switchWorkspaceViaSidebar, @@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s } await page.setViewportSize({ width: 1440, height: 1100 }); await gotoAppShell(page); + await revealSidebarProjectWorkspaces(page, project.projectKey); const order = await readVisibleWorkspaceOrder(page); await test.step("next crosses the ninth workspace and previous returns to it", async () => { @@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s workspaceId: order[8], }); }); + await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => { + await expectHiddenWorkspaceShortcuts(page, order); + }); } finally { await project.cleanup(); } }); + +async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) { + await page.getByLabel("Show less", { exact: true }).click(); + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(7); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await page.keyboard.press("Alt+9"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); + await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( + 0, + ); + await page.keyboard.press("Alt+]"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); + await expect(rows).toHaveCount(7); +} diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad index c94e24914d..359e89c5c9 100644 --- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0 env APP_ID=sh.paseo.debug # Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible, -# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms). +# unselected PINNED workspace row. Ordinary workspace rows are not draggable. +# Hold past drag arming (180ms), then release. open "${APP_ID}" press "label=\"Open menu\"" wait "id=\"${WORKSPACE_ROW}\"" 10000 diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index 9608a95a0c..c6d3579206 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -35,7 +35,8 @@ export const test = base.extend<{ import { readFile, rm } from "node:fs/promises"; export default function contribute(server) { return server.before("agent.session_open", async ({ request }) => { - if (request.reason !== "create") return request; + // Metadata helpers have no workspace and must not consume the one-shot failure. + if (request.reason !== "create" || !request.workspaceId) return request; const gate = ${JSON.stringify(gate)}; let command = await readFile(gate, "utf8").catch(() => "release"); while (command === "hold") { diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 9098068698..1c3d5dda89 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -17,6 +17,7 @@ import { workspaceDeckEntryLocator, } from "./workspace-ui"; import { getServerId } from "./server-id"; +import { revealSidebarProjectWorkspaces } from "./sidebar"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; @@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) { }); } await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await revealSidebarProjectWorkspaces(page, project.projectKey); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); await page .getByTestId(draftTestId!) diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 5b7b819bf0..66a913e7b3 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "@playwright/test"; import { getServerId } from "./server-id"; +import { projectEquivalenceViewKey } from "./project-view-key"; interface ContextMenuAnchor { x: number; @@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise { timeout: 5_000, }); } + +export async function revealSidebarProjectWorkspaces( + page: Page, + projectKey: string, +): Promise { + await page + .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`) + .click(); +} diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 73c008dd22..0e1d7d28ab 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -1,7 +1,8 @@ /** * @vitest-environment jsdom */ -import { act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,6 +84,8 @@ function workspace(input: { scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], - workspaceOrderByProject: { - ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`], - ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`], - }, }); }); } @@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => { useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); }); }); @@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => { }); }); }); + +describe("sidebar group visibility", () => { + const entries = (statuses: WorkspaceDescriptor["status"][]) => + statuses.map((status, index) => + createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: workspace({ + id: `row-${index + 1}`, + projectId: "project-a", + projectDisplayName: "Project A", + name: `Row ${index + 1}`, + status, + }), + }), + ); + const policy = { + kind: "workspaces" as const, + getStatusBucket: (entry: ReturnType) => entry.statusBucket, + }; + const names = (items: readonly ReturnType[]) => + items.map((item) => item.workspaceId); + + it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { + const items = entries(Array(count).fill("done")); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items.slice(0, 7)); + expect(result.current.canToggle).toBe(count > 7); + }); + + it.each(["needs_input", "failed", "attention", "running"] as const)( + "keeps row twelve visible for %s without filling gaps", + (status) => { + const items = entries([...Array(11).fill("done"), status]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-12", + ]); + expect(result.current.visibleItems[7]).toBe(items[11]); + expect(result.current.canToggle).toBe(true); + }, + ); + + it("shows eight active rows once with no redundant toggle", () => { + const items = entries([ + "running", + "needs_input", + "attention", + "failed", + "running", + "attention", + "needs_input", + "failed", + ]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items); + expect(result.current.canToggle).toBe(false); + }); + + it("recomputes current ordered membership and retains reveal through status, order and size changes", () => { + const items = entries([ + ...Array(7).fill("done"), + "running", + "done", + "failed", + "done", + "attention", + ]); + const { result, rerender, unmount } = renderHook( + ({ rows }) => useLimitedSidebarGroup(rows, policy), + { initialProps: { rows: items } }, + ); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-8", + "row-10", + "row-12", + ]); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) }); + expect(result.current.canToggle).toBe(false); + expect(result.current.expanded).toBe(true); + rerender({ rows: items.slice(0, 3) }); + expect(result.current.canToggle).toBe(false); + rerender({ rows: items.toReversed() }); + expect(result.current.visibleItems).toEqual(items.toReversed()); + act(() => result.current.toggleExpanded()); + expect(names(result.current.visibleItems)).toEqual([ + "row-12", + "row-11", + "row-10", + "row-9", + "row-8", + "row-7", + "row-6", + ]); + const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" }; + rerender({ rows: [newest, ...items] }); + expect(result.current.visibleItems[0]).toBe(newest); + expect(result.current.visibleItems).toHaveLength(10); + expect(result.current.expanded).toBe(false); + act(() => result.current.toggleExpanded()); + unmount(); + const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(remounted.result.current.expanded).toBe(false); + expect(remounted.result.current.visibleItems).toHaveLength(10); + }); + + it("follows effective pending creation, recovery and agent activity without changing Created order", () => { + const items = entries(Array(12).fill("done")); + const descriptor = workspace({ + id: "row-12", + projectId: "project-a", + projectDisplayName: "Project A", + name: "Row 12", + }); + const pending = { + draftId: "draft", + serverId: SERVER_ID, + workspaceId: descriptor.id, + agentId: null, + clientMessageId: "message", + text: "Create", + timestamp: 1234, + lifecycle: "active" as const, + }; + const { result, rerender } = renderHook( + ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), + { initialProps: { last: items[11] } }, + ); + expect(result.current.visibleItems).toHaveLength(7); + const creating = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: pending }, + }); + expect(creating.statusBucket).toBe("running"); + rerender({ last: creating }); + expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]); + const abandoned = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } }, + }); + rerender({ last: abandoned }); + expect(result.current.visibleItems).toHaveLength(7); + rerender({ last: creating }); + for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) { + const last = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + workspaceAgentActivity: new Map([ + [descriptor.id, { agentId: "agent", status, enteredAt: null }], + ]), + }); + expect(last.statusBucket).toBe(status); + rerender({ last }); + expect(names(result.current.visibleItems)).toEqual( + status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"], + ); + } + }); + + it("preserves the twenty-row pinned policy regardless of status", () => { + const items = entries(Array(21).fill("running")); + const { result } = renderHook(() => useLimitedSidebarGroup(items)); + expect(result.current.visibleItems).toEqual(items.slice(0, 20)); + expect(result.current.canToggle).toBe(true); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toHaveLength(20); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 74d57ca667..1c7b3f28d0 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps { shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; @@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({ ]; } -function noop() {} - const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", @@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1436,7 +1434,7 @@ function WorkspaceRowItem({ enabled: selectionEnabled, })} onPress={handlePress} - drag={drag ?? noop} + drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} /> @@ -1465,7 +1463,7 @@ function areWorkspaceRowItemPropsEqual( previous.hostBadge === next.hostBadge && previous.leadingProjectName === next.leadingProjectName && previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri && - previous.shortcutNumber === next.shortcutNumber && + (!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.canPin === next.canPin && @@ -1507,7 +1505,7 @@ function WorkspaceRow({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1552,16 +1550,12 @@ function ProjectBlock({ selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, - parentGestureRef, onToggleCollapsed, onWorkspacePress, - onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, - useNestable, - dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1577,16 +1571,12 @@ function ProjectBlock({ selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; - parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; - useNestable: boolean; - dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1594,12 +1584,20 @@ function ProjectBlock({ supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const visibilityPolicy = useMemo( + () => ({ + kind: "workspaces" as const, + getStatusBucket: (workspace: SidebarWorkspacePlacement) => + workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, + }), + [workspaceEntriesByKey], + ); const { visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces); + } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const preferredServerId = activeHostStatus === "online" ? activeServerId : null; @@ -1628,16 +1626,10 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - ( - item: SidebarWorkspacePlacement, - input?: { - drag?: () => void; - isDragging?: boolean; - dragHandleProps?: DraggableListDragHandleProps; - }, - ) => { + (item: SidebarWorkspacePlacement) => { return ( ); }, @@ -1671,29 +1660,6 @@ function ProjectBlock({ ], ); - const renderWorkspace = useCallback( - ({ - item, - drag: workspaceDrag, - isActive, - dragHandleProps: workspaceDragHandleProps, - }: DraggableRenderItemInfo) => { - return renderWorkspaceRow(item, { - drag: workspaceDrag, - isDragging: isActive, - dragHandleProps: workspaceDragHandleProps, - }); - }, - [renderWorkspaceRow], - ); - - const handleWorkspaceDragEnd = useCallback( - (workspaces: SidebarWorkspacePlacement[]) => { - onWorkspaceReorder(project.viewKey, workspaces); - }, - [onWorkspaceReorder, project.viewKey], - ); - const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1759,20 +1725,12 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - + style={styles.workspaceListContainer} + > + {visibleWorkspaces.map(renderWorkspaceRow)} + {canToggleWorkspaces ? ( [0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && - previous.workspaceEntriesByKey === next.workspaceEntriesByKey && + projectWorkspaceEntriesEqual(previous, next) && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && - previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + projectShortcutBadgesEqual(previous, next) && previous.hostBadgeByServerId === next.hostBadgeByServerId && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && - previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && - previous.onWorkspaceReorder === next.onWorkspaceReorder && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && - previous.useNestable === next.useNestable && - previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId && areProjectBlockSelectionsEqual(previous, next) ); } +function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { + if ( + !next.showShortcutBadges || + previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey + ) + return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.shortcutIndexByWorkspaceKey.get(workspaceKey) === + next.shortcutIndexByWorkspaceKey.get(workspaceKey), + ); +} + +function projectWorkspaceEntriesEqual( + previous: ProjectBlockProps, + next: ProjectBlockProps, +): boolean { + if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.workspaceEntriesByKey.get(workspaceKey) === + next.workspaceEntriesByKey.get(workspaceKey), + ); +} + function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, @@ -2137,8 +2116,6 @@ function ProjectModeList({ const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); - const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); - const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -2236,30 +2213,6 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); - const handleWorkspaceReorder = useCallback( - (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { - const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); - if ( - !hasVisibleOrderChanged({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) { - return; - } - - setWorkspaceOrder( - projectViewKey, - mergeWithRemainder({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }), - ); - }, - [getWorkspaceOrder, setWorkspaceOrder], - ); - const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2306,16 +2259,12 @@ function ProjectModeList({ selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} - parentGestureRef={parentGestureRef} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} - onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} - useNestable={platformIsNative} - dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2329,15 +2278,12 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, - handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, - parentGestureRef, - dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index be5a66ff6f..4fb04ebb67 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const hosts = useHosts(); // `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed // from it would lose the row that undoes the filter as soon as the filter narrowed to one. - const { allProjects, resolvedProjectFilters } = useSidebarModel(); + const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel(); const { labels } = useWorkspaceLabelProjection(); const [managerOpen, setManagerOpen] = useState(false); const openManager = useCallback(() => setManagerOpen(true), []); @@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, ]; + if (preferences.grouping === "project") { + definitions.push({ + id: "sort", + title: t("sidebar.display.sort.label"), + content: workspaceSortSupported ? ( + + ) : ( + {t("sidebar.display.sort.updateHost")} + ), + }); + } + if (showHostFilter) { definitions.push({ id: "hostFilter", @@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, [ t, preferences, + workspaceSortSupported, hosts, showHostFilter, showProjectFilter, @@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { > {t("sidebar.display.grouping.label")} + {preferences.grouping === "project" ? ( + + {t("sidebar.display.sort.label")} + + ) : null} setWorkspaceSort("created"), [setWorkspaceSort]); + const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); + return ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ); +} + /** * Every label you could filter by, wherever it lives, one row each. * diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index 68d347a258..a2ef3212e0 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useCallback, useMemo } from "react"; import { useAppSettings, @@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences { showBackground: boolean; toggleBackground: () => void; grouping: SidebarGroupMode; + workspaceSort: WorkspaceSort; + setWorkspaceSort: (sort: WorkspaceSort) => void; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; setTitleSource: (source: WorkspaceTitleSource) => void; @@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences { export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const showBackground = useSidebarViewStore((state) => state.showBackground); const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, titleSource: workspaceTitleSource, setTitleSource, @@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, workspaceTitleSource, setTitleSource, diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts new file mode 100644 index 0000000000..ce9f47671c --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -0,0 +1,21 @@ +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; + +export type SidebarGroupVisibilityPolicy = + | { kind: "pinned" } + | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; + +export function selectSidebarGroupVisibility( + items: readonly T[], + expanded: boolean, + policy: SidebarGroupVisibilityPolicy, +) { + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline = items.filter( + (item, index) => + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"), + ); + return { + visibleItems: expanded ? items.slice() : baseline, + canToggle: baseline.length < items.length, + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index ec834aee19..cfa69428f8 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -134,7 +134,7 @@ export function SidebarModelProvider({ visibleWorkspaceKeys.has(workspace.workspaceKey), ); return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) - ? [{ ...project, workspaces }] + ? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }] : []; }); } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index baffc3a010..28575df64e 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -319,6 +319,11 @@ function StatusGroupList({ ); } +const STATUS_GROUP_VISIBILITY = { + kind: "workspaces" as const, + getStatusBucket: (workspace: SidebarWorkspaceEntry) => workspace.statusBucket, +}; + function StatusGroupRows({ group, collapsed, @@ -345,7 +350,7 @@ function StatusGroupRows({ expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows); + } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); return ( diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index c5f8a278bd..2488e7956e 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -41,8 +41,6 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; -function noop() {} - interface SidebarWorkspaceRowProps { workspace: SidebarWorkspaceEntry; selected: boolean; @@ -239,7 +237,7 @@ function WorkspaceRowBody({ const trailing = useSidebarWorkspaceTrailing(); const draggable = Boolean(drag); const interaction = useLongPressDragInteraction({ - drag: drag ?? noop, + drag, menuController: null, }); const { diff --git a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts index 5bf10ecec6..99e4476dfb 100644 --- a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts +++ b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts @@ -1,15 +1,21 @@ import { useCallback, useMemo, useState } from "react"; +import { + selectSidebarGroupVisibility, + type SidebarGroupVisibilityPolicy, +} from "./sidebar-group-visibility"; -const INITIAL_VISIBLE_ITEMS = 20; +const PINNED_POLICY = { kind: "pinned" } as const; -export function useLimitedSidebarGroup(items: readonly T[]) { +export function useLimitedSidebarGroup( + items: readonly T[], + policy: SidebarGroupVisibilityPolicy = PINNED_POLICY, +) { const [expanded, setExpanded] = useState(false); - const visibleItems = useMemo( - () => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)), - [expanded, items], + const visibility = useMemo( + () => selectSidebarGroupVisibility(items, expanded, policy), + [expanded, items, policy], ); - const canToggle = items.length > INITIAL_VISIBLE_ITEMS; const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); - return { visibleItems, expanded, canToggle, toggleExpanded }; + return { ...visibility, expanded, toggleExpanded }; } diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index ae1d67dce3..f3de17b230 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import type { useContextMenu } from "@/components/ui/context-menu"; export function useLongPressDragInteraction(input: { - drag: () => void; + drag?: () => void; menuController: ReturnType | null; }) { const { drag, menuController } = input; @@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers(); - if (platformIsWeb) { + if (platformIsWeb || !drag) { return; } diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 7124fd2f0a..84e9141b6a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -31,6 +31,8 @@ function workspaceDescriptor(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); useSidebarViewStore.setState({ groupMode: "project", diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx index 96e1e1139a..5115666994 100644 --- a/packages/app/src/composer/diff-stat-pill.test.tsx +++ b/packages/app/src/composer/diff-stat-pill.test.tsx @@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill"; const WORKSPACE_ID = "workspace"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: WORKSPACE_ID, projectId: "project", projectDisplayName: "Project", diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c7ef8e281b..c0c3bea91f 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -8,6 +8,8 @@ import { } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "/repo/worktree", projectId: "/repo", projectDisplayName: "Repo", diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 1bc4cf2135..59648e237b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -9,6 +9,7 @@ import { buildSidebarWorkspacePlacementModel, buildSidebarProjectsFromStructure, computeSidebarOrderUpdates, + createSidebarWorkspacePlacementSelector, createSidebarWorkspaceEntry, deriveProjectStatusBucket, deriveSidebarLoadingState, @@ -20,6 +21,8 @@ import { function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "ws-1", projectId: "proj", projectDisplayName: "repo", @@ -150,6 +153,8 @@ function workspace(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -610,13 +615,12 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects: [], persistedProjectOrder: ["stale-project"], - getWorkspaceOrder: () => [], }); - expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); + expect(updates).toEqual({ projectOrder: null }); }); - it("appends unseen projects while putting unseen workspaces before the saved order", () => { + it("appends unseen projects without changing saved project order", () => { const projects = [ sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }), sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }), @@ -625,36 +629,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []), }); expect(updates.projectOrder).toEqual(["project-a", "project-b"]); - expect(updates.workspaceOrders).toEqual([ - { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, - { projectViewKey: "project-b", order: ["srv:ws-3"] }, - ]); - }); - - it("preserves the saved workspace order behind multiple newly discovered workspaces", () => { - const projects = [ - sidebarProject({ - projectKey: "project-a", - workspaceKeys: ["newest", "newer", "old-a", "old-b"], - }), - ]; - - const updates = computeSidebarOrderUpdates({ - projects, - persistedProjectOrder: ["project-a"], - getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"], - }); - - expect(updates.workspaceOrders).toEqual([ - { - projectViewKey: "project-a", - order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], - }, - ]); }); it("returns no project-order update when persisted order already covers visible keys", () => { @@ -666,11 +643,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-b", "project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]), }); expect(updates.projectOrder).toBeNull(); - expect(updates.workspaceOrders).toEqual([]); }); }); @@ -993,3 +968,14 @@ describe("deriveProjectStatusBucket", () => { ).toBe("done"); }); }); + +it("retains unrelated projects and keyed row placements when one project reorders", () => { + const select = createSidebarWorkspacePlacementSelector(); + const a = project({ projectKey: "a", workspaceKeys: ["srv:a1", "srv:a2"] }); + const b = project({ projectKey: "b", workspaceKeys: ["srv:b1"] }); + const before = select([a, b]); + const after = select([{ ...a, workspaceKeys: ["srv:a2", "srv:a1"] }, b]); + expect(after.projects[1]).toBe(before.projects[1]); + expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); + expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index e004c3fb89..cec59e071b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,4 @@ +import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; @@ -287,6 +288,34 @@ export function deriveProjectStatusBucket(input: { return aggregateSidebarStateBuckets(buckets); } +/** One bounded projection per sidebar; unchanged placements survive project reordering. */ +export function createSidebarWorkspacePlacementSelector(): ( + projects: readonly HostProjectListItem[], +) => SidebarWorkspacePlacementModel { + let previous: SidebarWorkspacePlacementModel | null = null; + return (projects) => { + const next = buildSidebarWorkspacePlacementModel({ projects }); + const previousProjects = new Map( + previous?.projects.map((project) => [project.viewKey, project]), + ); + const previousWorkspaces = new Map( + previous?.workspaces.map((workspace) => [workspace.workspaceKey, workspace]), + ); + next.projects = next.projects.map((project) => { + const prior = previousProjects.get(project.viewKey); + if (prior && equal(prior, project)) return prior; + project.workspaces = project.workspaces.map((workspace) => { + const priorWorkspace = previousWorkspaces.get(workspace.workspaceKey); + return priorWorkspace && equal(priorWorkspace, workspace) ? priorWorkspace : workspace; + }); + return project; + }); + next.workspaces = next.projects.flatMap((project) => project.workspaces); + previous = next; + return next; + }; +} + export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { @@ -555,56 +584,21 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } -export function prependMissingOrderKeys(input: { - currentOrder: string[]; - visibleKeys: string[]; -}): string[] { - if (input.visibleKeys.length === 0) { - return input.currentOrder; - } - - const existingKeys = new Set(input.currentOrder); - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); - if (missingKeys.length === 0) { - return input.currentOrder; - } - - return [...missingKeys, ...input.currentOrder]; -} - export interface SidebarOrderUpdates { projectOrder: string[] | null; - workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; - getWorkspaceOrder: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { - if (input.projects.length === 0) { - return { projectOrder: null, workspaceOrders: [] }; - } - const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; - - const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; - for (const project of input.projects) { - const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); - const nextWorkspaceOrder = prependMissingOrderKeys({ - currentOrder: persistedWorkspaceOrder, - visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }); - if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); - } - } - - return { projectOrder, workspaceOrders }; + return { + projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, + }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d8cc3383e5..5665b07e11 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,15 +1,16 @@ +import { useHostFeatureMap } from "@/runtime/host-features"; import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; -import { useHostProjects } from "@/projects/host-projects"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { - buildSidebarWorkspacePlacementModel, + createSidebarWorkspacePlacementSelector, computeSidebarOrderUpdates, createSidebarWorkspaceEntry, deriveProjectStatusBucket, @@ -85,6 +86,7 @@ const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = []; const EMPTY_PROJECT_NAMES = new Map(); export interface SidebarWorkspacesListResult { + workspaceSortSupported: boolean; workspacePlacements: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; projectNamesByViewKey: Map; @@ -138,14 +140,17 @@ export function useSidebarWorkspacesList(options?: { const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds); - const hostProjects = useHostProjects(directoryServerIds); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const groupMode = useSidebarViewStore((state) => state.groupMode); + const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); + const workspaceSortSupported = [...sortSupport.values()].every(Boolean); + const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); + const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); const sidebarModel = useMemo( - () => - buildSidebarWorkspacePlacementModel({ - projects: hostProjects, - }), - [hostProjects], + () => selectPlacements(hostProjects), + [hostProjects, selectPlacements], ); const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; @@ -161,16 +166,11 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, - getWorkspaceOrder: (projectViewKey) => - orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - for (const { projectViewKey, order } of updates.workspaceOrders) { - orderStore.setWorkspaceOrder(projectViewKey, order); - } }, [persistedProjectOrder, projects]); const refreshAll = useCallback(() => { @@ -193,6 +193,7 @@ export function useSidebarWorkspacesList(options?: { }); return { + workspaceSortSupported, workspacePlacements, projects, projectNamesByViewKey, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e28613bbff..16779d95e1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1166,6 +1166,12 @@ export const ar: TranslationResources = { showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", + sort: { + label: "الترتيب", + created: "تاريخ الإنشاء", + lastPrompt: "آخر طلب", + updateHost: "حدّث المضيفين لترتيب مساحات العمل", + }, grouping: { label: "التجميع", project: "المشروع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e056fdc855..6471b03f60 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1174,6 +1174,12 @@ export const en = { showBackground: "Show background", trigger: "Display preferences", heading: "Display", + sort: { + label: "Sort", + created: "Created", + lastPrompt: "Last prompt", + updateHost: "Update hosts to sort workspaces", + }, grouping: { label: "Grouping", project: "Project", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6d5f422e3a..82ab9e6977 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1202,6 +1202,12 @@ export const es: TranslationResources = { showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", + sort: { + label: "Orden", + created: "Creación", + lastPrompt: "Último prompt", + updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", + }, grouping: { label: "Agrupación", project: "Proyecto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3159dd4b61..44aae87628 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1195,6 +1195,12 @@ export const fr: TranslationResources = { showBackground: "Afficher les espaces en arrière-plan", trigger: "Préférences d’affichage", heading: "Affichage", + sort: { + label: "Tri", + created: "Création", + lastPrompt: "Dernier prompt", + updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", + }, grouping: { label: "Regroupement", project: "Projet", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1fcbbc7ad6..baa248aa31 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1179,6 +1179,12 @@ export const ja: TranslationResources = { showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", + sort: { + label: "並び順", + created: "作成日時", + lastPrompt: "最後のプロンプト", + updateHost: "ワークスペースを並べ替えるにはホストを更新してください", + }, grouping: { label: "グループ化", project: "プロジェクト", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index ea823a94ce..a573ef1ffa 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1173,6 +1173,12 @@ export const ko: TranslationResources = { showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", + sort: { + label: "정렬", + created: "생성일", + lastPrompt: "마지막 프롬프트", + updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", + }, grouping: { label: "그룹화", project: "프로젝트", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index de85b202c8..43c32d2914 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1192,6 +1192,12 @@ export const ptBR: TranslationResources = { showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", + sort: { + label: "Ordenação", + created: "Criação", + lastPrompt: "Último prompt", + updateHost: "Atualize os hosts para ordenar os espaços de trabalho", + }, grouping: { label: "Agrupamento", project: "Projeto", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5e70b0c1f4..586ad8595d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1183,6 +1183,12 @@ export const ru: TranslationResources = { showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", + sort: { + label: "Сортировка", + created: "Дата создания", + lastPrompt: "Последний запрос", + updateHost: "Обновите хосты для сортировки рабочих пространств", + }, grouping: { label: "Группировка", project: "Проект", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 96758eaca7..08bb8491cd 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1158,6 +1158,12 @@ export const zhCN: TranslationResources = { showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", + sort: { + label: "排序", + created: "创建时间", + lastPrompt: "最近提示词", + updateHost: "请更新主机以排序工作区", + }, grouping: { label: "分组", project: "项目", diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx index 0cb403f9b7..194a61efa3 100644 --- a/packages/app/src/panels/pull-request/activity.integration.test.tsx +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -124,6 +124,8 @@ beforeEach(async () => { workspaceKind: "local_checkout", name: "main", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index cbbbaaed4b..0c90185e53 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -3,6 +3,8 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 350f8855a7..ba8bc1431f 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -20,6 +20,8 @@ function project(input: { function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", @@ -174,3 +176,51 @@ describe("buildWorkspaceStructureProjects", () => { ); }); }); + +test("sorts by creation or human prompt without changing picker name order", () => { + const old = { + ...workspace("old", "p", "/p"), + name: "A", + createdAt: new Date("2026-01-01"), + lastUserMessageAt: new Date("2026-03-01"), + }; + const fresh = { + ...workspace("fresh", "p", "/p"), + name: "B", + createdAt: new Date("2026-02-01"), + lastUserMessageAt: null, + }; + const sessions = [ + { + serverId: "host", + projects: [project({ id: "p", key: "p", root: "/p" })], + workspaces: [old, fresh], + }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["host:fresh", "host:old"]); + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "lastPrompt" })[0].workspaceKeys, + ).toEqual(["host:old", "host:fresh"]); + expect(buildWorkspaceStructureProjects({ sessions })[0].workspaceKeys).toEqual([ + "host:old", + "host:fresh", + ]); +}); + +test("time ties use workspace identity so renaming cannot move Created rows", () => { + const a = { ...workspace("a", "p", "/p"), name: "Z", createdAt: new Date("2026-01-01") }; + const b = { ...workspace("b", "p", "/p"), name: "A", createdAt: a.createdAt }; + const sessions = [ + { serverId: "h", projects: [project({ id: "p", key: "p", root: "/p" })], workspaces: [b, a] }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); + a.name = "A"; + b.name = "Z"; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); +}); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 939e1381d2..92e6c68de4 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,6 +30,14 @@ interface WorkspaceStructureSession { workspaces: Iterable; } +export type WorkspaceSort = "created" | "lastPrompt"; + +interface WorkspaceSortItem { + workspaceName: string; + workspaceKey: string; + sortTime: number; +} + interface ProjectDraft { viewKey: string; projectKey: string | null; @@ -38,12 +46,13 @@ interface ProjectDraft { projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; hosts: Map; - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; + workspaces: WorkspaceSortItem[]; } /** The single app boundary that turns host-local projects into grouped display projects. */ export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; + workspaceSort?: WorkspaceSort; }): WorkspaceStructureProject[] { const byProject = new Map(); const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = []; @@ -84,7 +93,7 @@ export function buildWorkspaceStructureProjects(input: { const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); if (!viewKey) continue; byProject.get(viewKey)?.workspaces.push({ - workspaceId: workspace.id, + sortTime: workspaceSortTime(workspace, input.workspaceSort), workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, }); @@ -100,7 +109,7 @@ export function buildWorkspaceStructureProjects(input: { iconWorkingDir: draft.iconWorkingDir, hosts: Array.from(draft.hosts.values()), workspaceKeys: draft.workspaces - .sort(compareWorkspaceStructureItems) + .sort(input.workspaceSort ? compareWorkspaceTimes : compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) .sort( @@ -196,14 +205,27 @@ function getOrCreate(map: Map, key: K, create: () => V): V { return value; } -function compareWorkspaceStructureItems( - left: { workspaceId: string; workspaceName: string }, - right: { workspaceId: string; workspaceName: string }, -): number { +function compareWorkspaceStructureItems(left: WorkspaceSortItem, right: WorkspaceSortItem): number { return ( left.workspaceName.localeCompare(right.workspaceName, undefined, { numeric: true, sensitivity: "base", - }) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" }) + }) || left.workspaceKey.localeCompare(right.workspaceKey) ); } + +function workspaceSortTime( + workspace: WorkspaceDescriptor, + sort: WorkspaceSort | undefined, +): number { + if (!sort) return 0; + const time = + sort === "lastPrompt" + ? (workspace.lastUserMessageAt ?? workspace.createdAt) + : workspace.createdAt; + return time?.getTime() ?? 0; +} + +function compareWorkspaceTimes(left: WorkspaceSortItem, right: WorkspaceSortItem): number { + return right.sortTime - left.sortTime || left.workspaceKey.localeCompare(right.workspaceKey); +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 98e0ea29b3..6fae0a7e92 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1550,7 +1550,14 @@ it("fills every cached workspace beneath live updates received during the SQLite type: "workspace_update", payload: { kind: "upsert", - workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + workspace: { + ...workspaces[0], + createdAt: undefined, + lastUserMessageAt: null, + name: "Live", + activityAt: null, + statusEnteredAt: null, + }, }, }); }); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c48777a3e2..eaad715a52 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,37 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("preserves workspace sorting timestamps across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + createdAt: "2026-07-18T08:00:00.000Z", + lastUserMessageAt: "2026-07-18T09:00:00.000Z", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + expect((await reader.readDirectory(SERVER_ID)).workspaces.get(workspace.id)).toEqual(workspace); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace).toEqual(workspace); + }); + + it.each(["createdAt", "lastUserMessageAt"])( + "refetches workspaces cached without %s", + async (field) => { + const cached = await cacheWithoutBackgroundVisibility(); + const key = `${SERVER_ID}:workspace:workspace-1`; + const row = cached.storage.rows.get(key)!; + const payload = JSON.parse(row.payload); + payload.background = false; + delete payload[field]; + cached.storage.rows.set(key, { ...row, payload: JSON.stringify(payload) }); + await expectLegacyWorkspaceRefresh(cached); + }, + ); + it.each([true, false, undefined])( "preserves background visibility (%s) when reopening a cached workspace", async (background) => { diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 681d11a2c4..59578e65d7 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -296,6 +296,9 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. + createdAt: IsoDateSchema.nullable(), + lastUserMessageAt: IsoDateSchema.nullable(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), @@ -688,8 +691,14 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } +function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { + return normalizeWorkspaceDescriptor({ ...stored, createdAt: stored.createdAt ?? undefined }); +} + function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + createdAt: workspace.createdAt?.toISOString() ?? null, + lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, @@ -856,7 +865,7 @@ function applyDirectoryRow( case "workspace": { const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload); if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch"); - result.workspaces.set(row.id, normalizeWorkspaceDescriptor(stored)); + result.workspaces.set(row.id, restoreWorkspace(stored)); return; } case "project": { @@ -946,7 +955,7 @@ export class ReplicaCache { try { const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); - workspace = normalizeWorkspaceDescriptor(stored); + workspace = restoreWorkspace(stored); } catch { await this.deleteInvalidRow(workspaceRow); return undefined; diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 813cef01c0..1bb74ef4ea 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -5,6 +5,8 @@ import { resolveWorkspaceRouteState } from "./workspace-route-state"; function createWorkspaceDescriptor(): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 7e0b10e929..a24e49a672 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -14,6 +14,8 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; function createWorkspaceDescriptor(input: Partial = {}): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "/repo/main", projectId: "remote:github.com/getpaseo/paseo", projectDisplayName: "getpaseo/paseo", diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 89978811ec..48c5594f21 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -15,7 +16,6 @@ import { selectWorkspaceExists, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, workspaceEqualityFns, type WorkspaceStructure, @@ -99,10 +99,13 @@ export function useWorkspaceDirectory( ); } -export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { +export function useWorkspaceStructure( + serverIds: string[], + workspaceSort?: WorkspaceSort, +): WorkspaceStructure { const selectProjects = useMemo( - () => createWorkspaceStructureProjectsSelector(serverIds), - [serverIds], + () => createWorkspaceStructureProjectsSelector(serverIds, workspaceSort), + [serverIds, workspaceSort], ); const projects = useStoreWithEqualityFn( useSessionStore, @@ -114,20 +117,13 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); - const workspaceOrderByScope = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => selectWorkspaceOrderByScope(state), - workspaceEqualityFns.deep, - ); - return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, - workspaceOrderByScope, }), - [projectOrder, projects, workspaceOrderByScope], + [projectOrder, projects], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a79d30fe0d..a9c9d07772 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -14,7 +14,6 @@ import { selectWorkspaceDirectory, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, selectWorkspaceStructureProjects, workspaceEqualityFns, @@ -36,6 +35,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -118,7 +119,6 @@ function trackSelector( function emptySidebarOrder(): SidebarOrderSnapshot { return { projectOrder: [], - workspaceOrderByProject: {}, }; } @@ -364,7 +364,6 @@ describe("workspace structure composition", () => { return composeWorkspaceStructure({ projects: selectWorkspaceStructureProjects(useSessionStore.getState(), [serverId]), projectOrder: selectProjectOrder(sidebar), - workspaceOrderByScope: selectWorkspaceOrderByScope(sidebar), }); } @@ -619,3 +618,25 @@ describe("selectWorkspaceStatusesForBadges", () => { tracked.stop(); }); }); + +it("a prompt changes only its project's structure and output preserves both project identities", () => { + const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") }; + const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") }; + const c = { ...createWorkspace({ id: "c", projectId: "q" }), createdAt: new Date("2026-02-01") }; + const projects = new Map([a, c].map((w) => [w.projectId, projectDescriptorFromTestWorkspace(w)])); + const select = createWorkspaceStructureProjectsSelector([SERVER_ID], "lastPrompt"); + const state = (workspaces: WorkspaceDescriptor[]) => ({ + sessions: { [SERVER_ID]: { projects, workspaces: new Map(workspaces.map((w) => [w.id, w])) } }, + }); + const before = select(state([a, b, c])); + const prompted = { ...a, lastUserMessageAt: new Date("2026-03-01") }; + const after = select(state([prompted, b, c])); + expect(after.find((p) => p.viewKey === "p")?.workspaceKeys).toEqual([ + `${SERVER_ID}:a`, + `${SERVER_ID}:b`, + ]); + expect(after.find((p) => p.viewKey === "q")).toBe(before.find((p) => p.viewKey === "q")); + const output = select(state([{ ...prompted, status: "running" }, b, c])); + expect(output[0]).toBe(after[0]); + expect(output[1]).toBe(after[1]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 019b9d781b..6c1100edd6 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -1,6 +1,7 @@ import equal from "fast-deep-equal"; import { buildWorkspaceStructureProjects, + type WorkspaceSort, type WorkspaceStructure, type WorkspaceStructureProject, } from "@/projects/workspace-structure"; @@ -25,7 +26,6 @@ export interface SessionsSnapshot { export interface SidebarOrderSnapshot { projectOrder: string[]; - workspaceOrderByProject: Record; } const EMPTY_WORKSPACE_KEYS: string[] = []; @@ -154,6 +154,7 @@ export function selectWorkspaceDirectoryServerIds( export function selectWorkspaceStructureProjects( state: SessionsSnapshot, serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): WorkspaceStructureProject[] { const sessions: Array<{ serverId: string; @@ -179,11 +180,12 @@ export function selectWorkspaceStructureProjects( return EMPTY_WORKSPACE_STRUCTURE.projects; } - return buildWorkspaceStructureProjects({ sessions }); + return buildWorkspaceStructureProjects({ sessions, workspaceSort }); } export function createWorkspaceStructureProjectsSelector( serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): (state: SessionsSnapshot) => WorkspaceStructureProject[] { let previousInputs: Array<{ workspaces: Map | undefined; @@ -209,7 +211,12 @@ export function createWorkspaceStructureProjectsSelector( } previousInputs = inputs; - previousProjects = selectWorkspaceStructureProjects(state, serverIds); + const next = selectWorkspaceStructureProjects(state, serverIds, workspaceSort); + const previousByKey = new Map(previousProjects?.map((project) => [project.viewKey, project])); + previousProjects = next.map((project) => { + const previous = previousByKey.get(project.viewKey); + return previous && equal(previous, project) ? previous : project; + }); return previousProjects; }; } @@ -244,31 +251,16 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { return state.projectOrder ?? EMPTY_WORKSPACE_KEYS; } -export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record { - return state.workspaceOrderByProject ?? {}; -} - export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; - workspaceOrderByScope: Record; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; } const orderedProjects = applyStoredOrdering({ - items: input.projects.map((project) => { - const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS; - return { - ...project, - workspaceKeys: applyStoredOrdering({ - items: project.workspaceKeys, - storedOrder: workspaceOrder, - getKey: (workspaceKey) => workspaceKey, - }), - }; - }), + items: input.projects, storedOrder: input.projectOrder, getKey: (project) => project.viewKey, }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index c53eae4c49..86ea81afc5 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -64,6 +64,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dfeeadb7b0..aed06eef11 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -103,6 +103,8 @@ export interface Agent { } export interface WorkspaceDescriptor { + createdAt: Date | null; + lastUserMessageAt: Date | null; id: string; projectId: string; projectDisplayName: string; @@ -139,6 +141,9 @@ export function normalizeWorkspaceDescriptor( ? new Date(statusEnteredAtRaw) : null; return { + // COMPAT(workspaceSortTimes): added in v0.13.0, remove after 2027-04-22. + createdAt: payload.createdAt ? new Date(payload.createdAt) : null, + lastUserMessageAt: payload.lastUserMessageAt ? new Date(payload.lastUserMessageAt) : null, id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, projectId: payload.projectId, projectDisplayName: payload.projectDisplayName, diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index a68ca206f9..b34fb02240 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("prefixes legacy per-server workspace order with the source server id", () => { + it("discards legacy workspace ordering while retaining project ordering", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,15 +21,12 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], - workspaceOrderByProject: { - "project-a": ["host-a:main", "host-a:feature", "host-b:main"], - }, }); }); - it("normalizes pinned workspace order", () => { + it("deduplicates pinned workspace order without rewriting identities", () => { const migrated = migrateSidebarOrderState({ - pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"], + pinnedWorkspaceOrder: ["host-a:one", "host-a:one", "", "host-b:two"], }); expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); @@ -46,7 +43,6 @@ describe("sidebar order keys that end in whitespace", () => { useSidebarOrderStore.setState({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, }); }); @@ -56,17 +52,6 @@ describe("sidebar order keys that end in whitespace", () => { expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]); }); - it("scopes a workspace order under the project key the sidebar indexes by", () => { - useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]); - - // use-sidebar-workspaces-list reads workspaceOrderByProject by view key - // directly, so the scope has to be stored under that same key. - expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([ - "srv-1:main", - ]); - expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]); - }); - it("settles the sidebar order reconcile instead of rewriting it forever", () => { const projects: SidebarProjectEntry[] = [ { @@ -82,7 +67,6 @@ describe("sidebar order keys that end in whitespace", () => { computeSidebarOrderUpdates({ projects, persistedProjectOrder: useSidebarOrderStore.getState().projectOrder, - getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key), }); // First pass adds the newly visible project. @@ -95,11 +79,31 @@ describe("sidebar order keys that end in whitespace", () => { expect(runPass().projectOrder).toBeNull(); }); - it("still trims keys when migrating persisted state", () => { + it("still trims legacy keys when migrating per-host persisted state", () => { const migrated = migrateSidebarOrderState({ - projectOrder: [" host-a:one ", "host-a:one"], + projectOrderByServerId: { host: [" host-a:one ", "host-a:one"] }, }); expect(migrated.projectOrder).toEqual(["host-a:one"]); }); }); + +it("preserves project and pinned order while discarding ordinary workspace order", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }), + ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); +}); + +it("preserves whitespace in current project and pin identities during upgrade", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }), + ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 10831480f8..54f27e714f 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; - getWorkspaceOrder: (projectViewKey: string) => string[]; - setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -28,16 +25,12 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), + // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); -interface SidebarWorkspaceOrderScope { - serverId: string; - projectViewKey: string; -} - // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -78,46 +71,17 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } -function normalizeWorkspaceOrderByProject( - workspaceOrderByProject: Record | undefined, -): Record { - const normalized: Record = {}; - for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { - const scope = projectViewKey.trim(); - if (!scope) continue; - normalized[scope] = normalizeKeys(order); - } - return normalized; -} - -function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { - const separatorIndex = scopeKey.indexOf("::"); - if (separatorIndex < 0) return null; - const serverId = scopeKey.slice(0, separatorIndex).trim(); - const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); - if (!serverId || !projectViewKey) return null; - return { serverId, projectViewKey }; -} - -function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { - const workspaceKey = rawWorkspaceKey.trim(); - if (!workspaceKey) return null; - const serverPrefix = `${serverId}:`; - return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; -} - export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; + return { projectOrder: [], pinnedWorkspaceOrder: [] }; } const state: SidebarOrderPersistedState = result.data; - const projectOrder = normalizeKeys(state.projectOrder ?? []); + const projectOrder = dedupeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); for (const keys of Object.values(state.projectOrderByServerId ?? {})) { for (const key of normalizeKeys(keys)) { @@ -127,26 +91,9 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } - const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); - for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { - const scope = extractWorkspaceOrderScope(scopeKey); - if (!scope) continue; - const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; - const merged = [...existing]; - const seen = new Set(merged); - for (const key of order) { - const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); - if (!workspaceKey || seen.has(workspaceKey)) continue; - seen.add(workspaceKey); - merged.push(workspaceKey); - } - workspaceOrderByProject[scope.projectViewKey] = merged; - } - return { projectOrder, - pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []), - workspaceOrderByProject, + pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), }; } @@ -155,7 +102,6 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -164,19 +110,6 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, - getWorkspaceOrder: (projectViewKey) => { - if (!projectViewKey.trim()) return []; - return get().workspaceOrderByProject[projectViewKey] ?? []; - }, - setWorkspaceOrder: (projectViewKey, keys) => { - if (!projectViewKey.trim()) return; - set((state) => ({ - workspaceOrderByProject: { - ...state.workspaceOrderByProject, - [projectViewKey]: dedupeKeys(keys), - }, - })); - }, }), { name: "sidebar-project-workspace-order", @@ -184,9 +117,8 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, - workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 1, + version: 2, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index e21c8f8a15..a9e44e146b 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record): MemoryStor describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -85,6 +86,7 @@ describe("sidebar view store", () => { }, }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: [], @@ -100,6 +102,7 @@ describe("sidebar view store", () => { hostFilter: "host-a", }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a"], @@ -115,6 +118,7 @@ describe("sidebar view store", () => { hostFilters: ["host-a", "host-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a", "host-b"], @@ -226,6 +230,7 @@ describe("sidebar view store", () => { projectFilters: ["project-a", "project-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: ["host-a"], @@ -236,6 +241,7 @@ describe("sidebar view store", () => { it("never keeps project filters from state the schema rejects", () => { expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -287,3 +293,10 @@ describe("sidebar view store", () => { expect(storage.reads).toEqual(["sidebar-view"]); }); }); + +it("defaults to Created and persists the Last prompt choice across migration", () => { + expect(migrateSidebarViewState({}).workspaceSort).toBe("created"); + expect( + migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), + ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 0e12c304a8..229e95f96b 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; @@ -9,7 +10,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 7; +const SIDEBAR_VIEW_STORE_VERSION = 8; /** * The key standing for "this workspace carries no labels at all". @@ -47,6 +48,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] { interface SidebarViewStoreState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; @@ -64,6 +66,7 @@ interface SidebarViewStoreState { labelFilter: SidebarLabelFilter; toggleBackground: () => void; setGroupMode: (mode: SidebarGroupMode) => void; + setWorkspaceSort: (sort: WorkspaceSort) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; toggleProjectFilter: (viewKey: string) => void; @@ -76,6 +79,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; hostFilters: string[]; projectFilters: string[]; @@ -87,6 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ + workspaceSort: z.enum(["created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), @@ -128,6 +133,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -140,6 +146,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: legacyGroupMode, + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -149,6 +156,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: state.showBackground ?? false, groupMode: state.groupMode === "status" ? "status" : "project", + workspaceSort: state.workspaceSort ?? "created", hostFilters: readHostFilters(state), projectFilters: state.projectFilters ?? [], labelFilter: state.labelFilter @@ -187,11 +195,13 @@ export const useSidebarViewStore = create()( (set) => ({ showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })), setGroupMode: (mode) => set({ groupMode: mode }), + setWorkspaceSort: (workspaceSort) => set({ workspaceSort }), toggleHostFilter: (serverId) => set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), clearHostFilters: () => set({ hostFilters: [] }), @@ -239,6 +249,7 @@ export const useSidebarViewStore = create()( partialize: (state) => ({ showBackground: state.showBackground ?? false, groupMode: state.groupMode, + workspaceSort: state.workspaceSort, hostFilters: state.hostFilters, projectFilters: state.projectFilters, labelFilter: state.labelFilter, diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 0362aa56de..1acd5d8320 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -262,6 +262,8 @@ describe("replaceFetchedAgentDirectory", () => { workspaceKind: "worktree", name: "repo", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index c3416d19b1..e217f8d271 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -22,6 +22,8 @@ function descriptor( function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 0bed394be0..755a88c91f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -11,6 +11,8 @@ function workspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/utils/workspace-identity.test.ts b/packages/app/src/utils/workspace-identity.test.ts index b3324b939d..1287552ce7 100644 --- a/packages/app/src/utils/workspace-identity.test.ts +++ b/packages/app/src/utils/workspace-identity.test.ts @@ -6,6 +6,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index e7fe660f3b..3facdee34e 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -32,6 +32,8 @@ function archivePayload(input: { function workspace(input?: Partial): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1fcd96ed85..4beaf0a0b2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3781,6 +3781,7 @@ export const ServerInfoStatusPayloadSchema = z explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), + workspaceSortTimes: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), @@ -4126,6 +4127,8 @@ export const WorkspaceDescriptorPayloadSchema = z .nullish() .transform((value) => value ?? null), activityAt: z.string().nullable(), + createdAt: z.string().optional(), + lastUserMessageAt: z.string().nullable().optional(), diffStat: z .object({ additions: z.number(), diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 9df68dfde6..b15053ff04 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -7,8 +7,8 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; export class AgentDirectory { constructor( private readonly deps: { - manager: AgentManager; - storage: AgentStorage; + manager: Pick; + storage: Pick; workspaces: Pick; projectLive(agent: ManagedAgent): Promise; projectStored(record: StoredAgentRecord): AgentSnapshotPayload; @@ -17,6 +17,26 @@ export class AgentDirectory { }, ) {} + /** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */ + async workspaceLastUserMessageTimes(): Promise> { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const times = new Map(); + const record = (workspaceId: string | undefined, at: string | null | undefined) => { + if (!workspaceId || !at) return; + const previous = times.get(workspaceId); + if (!previous || at > previous) times.set(workspaceId, at); + }; + for (const agent of await this.deps.storage.list()) { + if (!agent.internal && !liveIds.has(agent.id)) + record(agent.workspaceId, agent.lastUserMessageAt); + } + for (const agent of live) { + if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); + } + return times; + } + async includes( agent: Pick, includeBackground = false, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 14495fedec..e5fdbca32f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1228,6 +1228,7 @@ export class Session { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), + workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), @@ -5649,6 +5650,7 @@ export class Session { status: "done", statusEnteredAt: null, activityAt: null, + createdAt: workspace.createdAt, diffStat, scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord @@ -5745,6 +5747,8 @@ export class Session { status: "done", statusEnteredAt: result.workspace.createdAt, activityAt: null, + createdAt: result.workspace.createdAt, + lastUserMessageAt: null, diffStat: { additions: 0, deletions: 0 }, scripts: [], gitRuntime: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2dfddef55e..dcc1d65b40 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -6420,7 +6420,9 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); expect(describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); - expect(descriptors).toEqual([baselineDescriptor]); + expect(descriptors).toEqual([ + { ...baselineDescriptor, createdAt: workspace.createdAt, lastUserMessageAt: null }, + ]); }); test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d896bcf37d..adbc0bd38f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1963,6 +1963,7 @@ export class VoiceAssistantWebSocketServer { // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, + workspaceSortTimes: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index e317b5427d..be59c3a24a 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -1,3 +1,8 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent/agent-storage.js"; +import { AgentDirectory } from "./agent/directory.js"; import { describe, expect, test } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -70,6 +75,16 @@ class WorkspaceStatus { projectRegistry: { list: async () => [this.project] }, workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, + workspaceLastUserMessageTimes: async () => { + const times = new Map(); + for (const agent of this.agents) { + if (!agent.workspaceId || !agent.lastUserMessageAt) continue; + const previous = times.get(agent.workspaceId); + if (!previous || agent.lastUserMessageAt > previous) + times.set(agent.workspaceId, agent.lastUserMessageAt); + } + return times; + }, listProviderSubagentActivity: async () => this.providerSubagents, listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, @@ -242,6 +257,8 @@ class WorkspaceStatus { interface AgentState { id: string; status: AgentSnapshotPayload["status"]; + lastUserMessageAt?: string | null; + archivedAt?: string | null; pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; @@ -261,7 +278,7 @@ function createAgent( effectiveThinkingOptionId: null, createdAt: NOW, updatedAt: NOW, - lastUserMessageAt: null, + lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status, capabilities: { supportsStreaming: true, @@ -289,7 +306,7 @@ function createAgent( requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: null, - archivedAt: null, + archivedAt: input.archivedAt ?? null, } satisfies AgentSnapshotPayload; } @@ -560,6 +577,7 @@ describe("WorkspaceDirectory empty projects", () => { projectRegistry: { list: async () => input.projects }, workspaceRegistry: { list: async () => input.workspaces }, listAgentPayloads: async () => [], + workspaceLastUserMessageTimes: async () => new Map(), listProviderSubagentActivity: async () => [], listTerminalActivityContributions: async () => [], isProviderVisibleToClient: () => true, @@ -723,6 +741,7 @@ test("Git observation targets exclude archived records without hydrating app des ], }, listAgentPayloads: unexpectedHydration, + workspaceLastUserMessageTimes: unexpectedHydration, listProviderSubagentActivity: unexpectedHydration, listTerminalActivityContributions: unexpectedHydration, buildWorkspaceDescriptor: unexpectedHydration, @@ -732,3 +751,70 @@ test("Git observation targets exclude archived records without hydrating app des { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, ]); }); + +test("workspace sort times retain the latest human prompt after its agent is archived", async () => { + const scenario = new WorkspaceStatus(); + scenario.hasRootAgent({ + id: "older", + status: "idle", + lastUserMessageAt: "2026-03-03T12:00:00.000Z", + }); + scenario.hasRootAgent({ + id: "archived", + status: "idle", + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + archivedAt: "2026-03-05T12:00:00.000Z", + }); + scenario.hasRootAgent({ id: "output-only", status: "running" }); + expect(await scenario.workspaceDescriptor()).toMatchObject({ + createdAt: NOW, + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + status: "running", + }); +}); + +test("prompt recency reads archived records without projecting agents or exposing internal prompts", async () => { + const home = await mkdtemp(join(tmpdir(), "workspace-prompt-times-")); + const storage = new AgentStorage(home, createTestLogger()); + try { + const base = { + provider: "unavailable", + cwd: home, + workspaceId: "w", + createdAt: NOW, + updatedAt: NOW, + labels: {}, + lastStatus: "closed" as const, + }; + await storage.upsert({ + ...base, + id: "archived", + archivedAt: NOW, + lastUserMessageAt: "2026-03-04T00:00:00.000Z", + }); + await storage.upsert({ + ...base, + id: "internal", + internal: true, + lastUserMessageAt: "2026-03-05T00:00:00.000Z", + }); + await storage.upsert({ ...base, id: "output", lastActivityAt: "2026-03-06T00:00:00.000Z" }); + const unexpectedProjection = (): never => { + throw new Error("Prompt sorting projected an agent payload"); + }; + const directory = new AgentDirectory({ + storage, + manager: { listAgents: () => [] }, + workspaces: { get: async () => null, list: async () => [] }, + projectLive: unexpectedProjection, + projectStored: unexpectedProjection, + isProviderVisible: () => false, + isStoredProviderAvailable: () => false, + }); + expect(await directory.workspaceLastUserMessageTimes()).toEqual( + new Map([["w", "2026-03-04T00:00:00.000Z"]]), + ); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8ccb4fa341..50bc58a06d 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -77,6 +77,7 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; listAgentPayloads(): Promise; + workspaceLastUserMessageTimes(): Promise>; listProviderSubagentActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> @@ -218,12 +219,14 @@ export class WorkspaceDirectory { }): Promise> { const [ agents, + lastUserMessageTimes, providerSubagentActivity, persistedWorkspaces, persistedProjects, terminalContributions, ] = await Promise.all([ this.deps.listAgentPayloads(), + this.deps.workspaceLastUserMessageTimes(), this.deps.listProviderSubagentActivity(), this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), @@ -258,6 +261,8 @@ export class WorkspaceDirectory { const workspaceId = includedWorkspaces[i].workspaceId; descriptorsByWorkspaceId.set(workspaceId, { ...workspaceDescriptors[i], + createdAt: includedWorkspaces[i].createdAt, + lastUserMessageAt: lastUserMessageTimes.get(workspaceId) ?? null, archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null, }); } diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index a80b84abd8..ebb29f0237 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -19,7 +19,9 @@ export default defineConfig({ }, { command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", - url: baseURL, + // Probe the server without running the homepage loader's live GitHub requests. + url: new URL("/logo.svg", baseURL).href, + stdout: "pipe", env: { CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188",