fix(app): keep live workspace rows when a late creation snapshot arrives (#7758)

A workspace.create.response carries the descriptor captured at provision
time but is sent after the initial agent starts. A pin or title change made
in between reaches the app first as a live update; the late response then
replaced that row with the older snapshot, so a new pin vanished from the
sidebar until the next directory update.

Creation and progress snapshots now only seed rows the replica has not
received; existing rows stay owned by directory updates. Archive rollback
still restores a row that was removed.

Closes #5447

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stephen Zhou
2026-10-24 01:05:57 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 48d2c9ec36
commit 632a7f8859
3 changed files with 218 additions and 1 deletions
@@ -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<string> {
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<number>> {
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();
}
});
});
@@ -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);
}
});
@@ -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[] {