Sort sidebar workspaces and keep active overflow visible (#7635)

Order ordinary sidebar workspaces by Created or Last prompt, preserve project and pin ordering, and keep later active or unacknowledged work visible beyond the first seven rows. Preserve reveal state through live updates and verify sorting, visibility, compatibility, and rendering behavior.
This commit is contained in:
Mohamed Boudra
2026-10-22 19:59:28 +07:00
committed by GitHub
parent 793b16c0df
commit 55a7590952
66 changed files with 1225 additions and 362 deletions
+1
View File
@@ -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.
@@ -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",
@@ -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",
@@ -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<void> {
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<void> {
await page.reload();
await waitForSidebarHydration(page);
}
async function expectNoWorkspaceSortChoice(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
}
async function promptWorkspace(seed: Awaited<ReturnType<typeof seedWorkspace>>, 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<ReturnType<typeof seedCompactProject>>;
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();
}
});
@@ -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);
}
@@ -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);
}
@@ -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
@@ -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") {
@@ -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!)
@@ -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<void> {
timeout: 5_000,
});
}
export async function revealSidebarProjectWorkspaces(
page: Page,
projectKey: string,
): Promise<void> {
await page
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
.click();
}
@@ -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<typeof createSidebarWorkspaceEntry>) => entry.statusBucket,
};
const names = (items: readonly ReturnType<typeof createSidebarWorkspaceEntry>[]) =>
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);
});
});
@@ -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<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -1594,12 +1584,20 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
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 (
<MemoWorkspaceRowItem
key={item.workspaceKey}
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
@@ -1650,9 +1642,6 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
},
@@ -1671,29 +1660,6 @@ function ProjectBlock({
],
);
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
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 = (
<>
<DraggableList
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map(renderWorkspaceRow)}
</View>
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1834,33 +1792,54 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[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,
@@ -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 ? (
<SortPage preferences={preferences} />
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
),
});
}
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")}
</MenuSubTrigger>
{preferences.grouping === "project" ? (
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
>
{t("sidebar.display.sort.label")}
</MenuSubTrigger>
) : null}
<MenuSubTrigger
id="titleSource"
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
@@ -374,6 +399,23 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
);
}
/**
* Every label you could filter by, wherever it lives, one row each.
*
@@ -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,
@@ -0,0 +1,21 @@
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
policy: SidebarGroupVisibilityPolicy<T>,
) {
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,
};
}
@@ -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 }]
: [];
});
}
@@ -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 (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -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 {
@@ -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<T>(items: readonly T[]) {
export function useLimitedSidebarGroup<T>(
items: readonly T[],
policy: SidebarGroupVisibilityPolicy<T> = 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 };
}
@@ -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<typeof useContextMenu> | null;
}) {
const { drag, menuController } = input;
@@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: {
const armTimers = useCallback(() => {
clearTimers();
if (platformIsWeb) {
if (platformIsWeb || !drag) {
return;
}
@@ -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",
@@ -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",
@@ -8,6 +8,8 @@ import {
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "/repo/worktree",
projectId: "/repo",
projectDisplayName: "Repo",
@@ -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]);
});
@@ -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 {
@@ -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<string, string>();
export interface SidebarWorkspacesListResult {
workspaceSortSupported: boolean;
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByViewKey: Map<string, string>;
@@ -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,
+6
View File
@@ -1166,6 +1166,12 @@ export const ar: TranslationResources = {
showBackground: "إظهار مساحات العمل في الخلفية",
trigger: "تفضيلات العرض",
heading: "العرض",
sort: {
label: "الترتيب",
created: "تاريخ الإنشاء",
lastPrompt: "آخر طلب",
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
},
grouping: {
label: "التجميع",
project: "المشروع",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -1179,6 +1179,12 @@ export const ja: TranslationResources = {
showBackground: "バックグラウンドを表示",
trigger: "表示設定",
heading: "表示",
sort: {
label: "並び順",
created: "作成日時",
lastPrompt: "最後のプロンプト",
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
},
grouping: {
label: "グループ化",
project: "プロジェクト",
+6
View File
@@ -1173,6 +1173,12 @@ export const ko: TranslationResources = {
showBackground: "백그라운드 표시",
trigger: "표시 설정",
heading: "표시",
sort: {
label: "정렬",
created: "생성일",
lastPrompt: "마지막 프롬프트",
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
},
grouping: {
label: "그룹화",
project: "프로젝트",
+6
View File
@@ -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",
+6
View File
@@ -1183,6 +1183,12 @@ export const ru: TranslationResources = {
showBackground: "Показать фоновые пространства",
trigger: "Настройки отображения",
heading: "Отображение",
sort: {
label: "Сортировка",
created: "Дата создания",
lastPrompt: "Последний запрос",
updateHost: "Обновите хосты для сортировки рабочих пространств",
},
grouping: {
label: "Группировка",
project: "Проект",
+6
View File
@@ -1158,6 +1158,12 @@ export const zhCN: TranslationResources = {
showBackground: "显示后台工作区",
trigger: "显示偏好",
heading: "显示",
sort: {
label: "排序",
created: "创建时间",
lastPrompt: "最近提示词",
updateHost: "请更新主机以排序工作区",
},
grouping: {
label: "分组",
project: "项目",
@@ -124,6 +124,8 @@ beforeEach(async () => {
workspaceKind: "local_checkout",
name: "main",
status: "done",
createdAt: null,
lastUserMessageAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
@@ -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",
@@ -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"]);
});
@@ -30,6 +30,14 @@ interface WorkspaceStructureSession {
workspaces: Iterable<WorkspaceDescriptor>;
}
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<string, WorkspaceStructureHostPlacement>;
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<string, ProjectDraft>();
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<K, V>(map: Map<K, V>, 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);
}
@@ -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,
},
},
});
});
@@ -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) => {
@@ -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;
@@ -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",
@@ -14,6 +14,8 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: "/repo/main",
projectId: "remote:github.com/getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
@@ -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],
);
}
@@ -14,7 +14,6 @@ import {
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScope,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
@@ -36,6 +35,8 @@ function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): 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<S, T>(
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]);
});
@@ -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<string, string[]>;
}
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<string, WorkspaceDescriptor> | 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<string, string[]> {
return state.workspaceOrderByProject ?? {};
}
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByScope: Record<string, readonly string[]>;
}): 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,
});
@@ -64,6 +64,8 @@ function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
+5
View File
@@ -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,
@@ -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 "] });
});
+5 -73
View File
@@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
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<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
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<string, string[]>;
} {
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<SidebarOrderStoreState>()(
(set, get) => ({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
set({ projectOrder: dedupeKeys(keys) });
@@ -164,19 +110,6 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
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<SidebarOrderStoreState>()(
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 1,
version: 2,
migrate: migrateSidebarOrderState,
},
),
@@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record<string, string | null>): 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" });
});
+12 -1
View File
@@ -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<SidebarViewStoreState>()(
(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<SidebarViewStoreState>()(
partialize: (state) => ({
showBackground: state.showBackground ?? false,
groupMode: state.groupMode,
workspaceSort: state.workspaceSort,
hostFilters: state.hostFilters,
projectFilters: state.projectFilters,
labelFilter: state.labelFilter,
@@ -262,6 +262,8 @@ describe("replaceFetchedAgentDirectory", () => {
workspaceKind: "worktree",
name: "repo",
status: "done",
createdAt: null,
lastUserMessageAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
+2
View File
@@ -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",
@@ -11,6 +11,8 @@ function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
@@ -6,6 +6,8 @@ function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
@@ -32,6 +32,8 @@ function archivePayload(input: {
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Project",
+3
View File
@@ -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(),
+22 -2
View File
@@ -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<AgentManager, "listAgents">;
storage: Pick<AgentStorage, "list">;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
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<Map<string, string>> {
const live = this.deps.manager.listAgents();
const liveIds = new Set(live.map((agent) => agent.id));
const times = new Map<string, string>();
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<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
+4
View File
@@ -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: {
@@ -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 () => {
@@ -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.
@@ -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<string, string>();
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 });
}
});
@@ -77,6 +77,7 @@ export interface WorkspaceDirectoryDeps {
list(): Promise<PersistedWorkspaceRecord[]>;
};
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
workspaceLastUserMessageTimes(): Promise<ReadonlyMap<string, string>>;
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
listTerminalActivityContributions(): Promise<
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
@@ -218,12 +219,14 @@ export class WorkspaceDirectory {
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
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,
});
}
+3 -1
View File
@@ -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",