From 4b3b1e3e4ed78c2a7cc806cd4155fbe4a2dcd540 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 06:21:37 +0100 Subject: [PATCH] fix(app): keep a workspace's layout when another client restores it during the undo window The undo window purged tabs and layout whenever it closed after a finished archive. If another client restored the workspace before this client's toast closed, the restored workspace lost its saved arrangement. The archive now commits only if the workspace is still gone when the window closes. --- .../archive-undo/internal/undo-window.test.ts | 18 ++++++++++++++++++ .../archive-undo/internal/undo-window.tsx | 14 +++++++++++--- .../app/src/workspace/workspace-archive.ts | 17 +++++++++++++---- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts index 1e36395f2a..efa9a539ac 100644 --- a/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts @@ -164,6 +164,24 @@ describe("archiveWorkspaceWithUndo", () => { }); }); + it("keeps the layout when another client restores the workspace before the toast closes", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + host.finishArchive("a"); + await archive; + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" }); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a")]); + toast.timeOut(); + + expect({ committed, row: storedWorkspace("a") }).toEqual({ + committed: [], + row: workspace("a"), + }); + }); + it("commits when the archive finishes after its toast already timed out", async () => { const host = createClient(); const toast = createToastHost(); diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.tsx b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx index 65b5d8419e..5dc968124e 100644 --- a/packages/app/src/workspace/archive-undo/internal/undo-window.tsx +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx @@ -3,6 +3,7 @@ import { i18n } from "@/i18n/i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { beginOptimisticWorkspaceArchive, + isWorkspaceListed, undoWorkspaceArchiveOptimistically, type WorkspaceArchiveTarget, } from "@/workspace/workspace-archive"; @@ -50,7 +51,7 @@ class UndoWindow { /** The host finished the archive. It commits now if undo is no longer offered. */ archiveFinished(): void { this.archived = true; - if (this.phase === "closed") this.input.onArchiveCommitted(); + if (this.phase === "closed") this.commit(); } /** The archive failed on the host, so there is nothing left to undo. */ @@ -63,9 +64,15 @@ class UndoWindow { if (this.phase !== "open") return; this.phase = "closed"; this.release(); - if (this.archived) this.input.onArchiveCommitted(); + if (this.archived) this.commit(); }; + /** Commits unless the workspace is back by now, for example restored from another client. */ + private commit(): void { + if (isWorkspaceListed(this.input.workspace)) return; + this.input.onArchiveCommitted(); + } + readonly undo = (): boolean => { if (this.phase !== "open") return false; this.phase = "undone"; @@ -87,7 +94,8 @@ class UndoWindow { * Archives a workspace and offers to undo it until the toast goes away. * * `onArchiveCommitted` runs once the archive both finished on the host and can no longer be - * undone, so local state the undo would want back (tabs, layout) survives the window. + * undone, and only if the workspace is still gone then. Local state the undo would want back + * (tabs, layout) survives the window, and survives a restore from another client. * Rejects when the archive fails and was not undone; the row is back by then. */ export async function archiveWorkspaceWithUndo(input: { diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index cc2a24db97..fd757934ef 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -55,15 +55,24 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai ); } -function hideWorkspaceOptimistically( - workspace: WorkspaceArchiveTarget, -): OptimisticWorkspaceArchiveSnapshot { +function findListedWorkspace(workspace: WorkspaceArchiveTarget): WorkspaceDescriptor | null { const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, }); - const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; + return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; +} + +/** True when the workspace is in this client's list, for example after another client restored it. */ +export function isWorkspaceListed(workspace: WorkspaceArchiveTarget): boolean { + return findListedWorkspace(workspace) !== null; +} + +function hideWorkspaceOptimistically( + workspace: WorkspaceArchiveTarget, +): OptimisticWorkspaceArchiveSnapshot { + const snapshot = findListedWorkspace(workspace); const intent = markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId,