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