diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts new file mode 100644 index 0000000000..3ed0755fc9 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts @@ -0,0 +1,100 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +// The creation response carries the descriptor captured when the workspace was provisioned. With an +// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's +// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The +// late response must not put the old, unpinned row back. + +function pinnedRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise { + let createdId: string | undefined; + await expect + .poll( + async () => { + const { entries } = await seeded.client.fetchWorkspaces({ + filter: { projectId: seeded.projectId }, + }); + createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id; + return createdId; + }, + { timeout: 30_000 }, + ) + .toBeTruthy(); + return createdId!; +} + +// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts. +async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise> { + await page.evaluate((rowTestId) => { + const state = { missingFrames: 0, stopped: false }; + (window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state; + const sample = () => { + if (state.stopped) return; + const section = document.querySelector('[data-testid="sidebar-pinned-section"]'); + if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, testId); + return () => + page.evaluate(() => { + const state = ( + window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } } + ).__pinnedRowWatch; + state.stopped = true; + return state.missingFrames; + }); +} + +test.describe("New workspace live pin", () => { + test("keeps a pin made before the creation response arrives", async ({ page }) => { + test.setTimeout(120_000); + const creation = await delayBrowserAgentCreatedStatus(page); + const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, seeded); + await submitNewWorkspacePrompt(page, "Pin me while I start"); + + await creation.waitForCreateRequest(); + // The response is held here, carrying the descriptor from before the pin. + await creation.waitForDelayedCreatedStatus(); + const workspaceId = await findCreatedWorkspaceId(seeded); + const rowTestId = pinnedRowTestId(workspaceId); + + await seeded.client.setWorkspacePinned(workspaceId, true); + const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId); + await expect(pinnedRow).toBeVisible({ timeout: 10_000 }); + + const stopWatching = await watchPinnedRow(page, rowTestId); + creation.release(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({ + timeout: 30_000, + }); + // Leave time for the released response to be merged and rendered. + await page.waitForTimeout(1_000); + + expect(await stopWatching()).toBe(0); + await expect(pinnedRow).toBeVisible(); + } finally { + creation.release(); + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index c9a674f3b3..792c005100 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false); expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); }); + +it("keeps a live workspace update when an older creation snapshot arrives", () => { + const serverId = "workspace-create-after-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = workspace("new"); + const pinned = { + ...initial, + pinnedAt: "2026-09-26T00:00:00.000Z", + title: "Updated title", + }; + + try { + replica.applyDelta({ kind: "upsert", workspace: pinned }); + const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id); + + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]); + expect(replica.snapshot().workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(pinned), + ); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe( + accepted, + ); + } finally { + store.clearSession(serverId); + } +}); + +it("seeds missing workspaces before live updates and in a mixed creation batch", () => { + const serverId = "workspace-create-before-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + const second = normalizeWorkspaceDescriptor(workspace("second")); + const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" }; + + try { + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + replica.applyDelta({ kind: "upsert", workspace: pinned }); + expect(replica.acceptWorkspaces([initial, second])).toEqual([ + { kind: "workspace", type: "upsert", id: second.id, value: second }, + ]); + expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([ + normalizeWorkspaceDescriptor(pinned), + second, + ]); + } finally { + store.clearSession(serverId); + } +}); + +it("keeps a manual unpin when a stale pinned snapshot arrives", () => { + const serverId = "workspace-manual-unpin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" }; + const unpinned = { ...pinned, pinnedAt: null }; + + try { + replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]); + replica.applyDelta({ kind: "upsert", workspace: unpinned }); + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual( + normalizeWorkspaceDescriptor(unpinned), + ); + } finally { + store.clearSession(serverId); + } +}); + +it("restores a missing workspace after an archive failure without replacing a live row", () => { + const serverId = "workspace-archive-rollback"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + + try { + replica.acceptWorkspaces([initial]); + markWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + replica.removeWorkspaceSnapshot(initial.id); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false); + + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + const updated = { ...workspace(initial.id), title: "Updated during archive" }; + replica.applyDelta({ kind: "upsert", workspace: updated }); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(updated), + ); + } finally { + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + store.clearSession(serverId); + } +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 2eb107ac4f..8f21ebd214 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica { } acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { - return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + // Creation responses and progress snapshots can arrive after live updates. + // They seed missing rows; the directory owns rows it has already received. + return workspaces.flatMap((workspace) => { + if (this.workspaces.has(workspace.id)) return []; + return this.upsertWorkspace(workspace); + }); } acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] {