fix: keep archived worktrees optimistically hidden (#640)

This commit is contained in:
Mohamed Boudra
2026-04-30 21:36:41 +08:00
committed by GitHub
parent 92afb826d9
commit 93c6c3e5f8
32 changed files with 712 additions and 112 deletions
@@ -83,6 +83,7 @@ function workspace(input: {
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -1462,7 +1462,7 @@ function WorkspaceRowWithMenu({
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -31,6 +31,7 @@ function workspaceDescriptor(input: {
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
@@ -18,6 +18,7 @@ function workspace(input: {
workspaceKind: "checkout",
name: "main",
status: "running",
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -41,6 +41,7 @@ import {
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -58,6 +59,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -1232,6 +1234,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId,
workspace,
isArchivePending: isLocalWorktreeArchivePending,
})
) {
return;
}
mergeWorkspaces(serverId, [workspace]);
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
id: "/repo/worktree",
projectId: "/repo",
projectDisplayName: "Repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "done",
archivingAt: "2026-04-30T00:00:00.000Z",
diffStat: null,
scripts: [],
};
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return { ...baseWorkspace, ...input };
}
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
it("suppresses archiving upserts for a locally pending archive", () => {
const isArchivePending = vi.fn(() => true);
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
isArchivePending,
}),
).toBe(true);
expect(isArchivePending).toHaveBeenCalledWith({
serverId: "server-1",
cwd: "/repo/worktree",
});
});
it("allows archiving upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace(),
isArchivePending: () => false,
}),
).toBe(false);
});
it("allows normal upserts while a local archive is pending", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
isArchivePending: () => true,
}),
).toBe(false);
});
});
@@ -0,0 +1,15 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
serverId: string;
workspace: WorkspaceDescriptor;
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
}): boolean {
return (
input.workspace.archivingAt !== null &&
input.isArchivePending({
serverId: input.serverId,
cwd: input.workspace.workspaceDirectory,
})
);
}
@@ -78,6 +78,7 @@ describe("openProjectDirectly", () => {
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
archivingAt: null,
status: "done" as const,
activityAt: null,
diffStat: null,
@@ -87,6 +87,7 @@ function workspace(input: {
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
@@ -57,6 +57,7 @@ function workspaceDescriptor(id: string): WorkspaceDescriptor {
workspaceKind: "worktree",
name: id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
@@ -28,6 +28,7 @@ export interface SidebarWorkspaceEntry {
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
statusBucket: SidebarStateBucket;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptor["scripts"];
hasRunningScripts: boolean;
@@ -65,6 +66,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKind: "checkout",
name: input.workspaceId,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
@@ -86,6 +88,7 @@ export function createSidebarWorkspaceEntry(input: {
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
@@ -26,6 +26,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
diffStat: null,
scripts: [],
...input,
archivingAt: input.archivingAt ?? null,
};
}
@@ -7,6 +7,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
@@ -39,6 +40,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
@@ -207,6 +209,7 @@ describe("checkout-git-actions-store", () => {
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
]);
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
@@ -233,4 +236,25 @@ describe("checkout-git-actions-store", () => {
listSnapshot,
);
});
it("reports local archive pending only while the archive action is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
});
});
@@ -191,7 +191,6 @@ function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath:
function restoreWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
snapshot: WorktreeArchiveSnapshot;
}): void {
if (input.snapshot.workspace) {
@@ -223,6 +222,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
return `${key}::${actionId}`;
}
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
return (
useCheckoutGitActionsStore.getState().getStatus({
serverId: input.serverId,
cwd: input.cwd,
actionId: "archive-worktree",
}) === "pending"
);
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
@@ -436,7 +445,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
throw new Error(payload.error.message);
}
} catch (error) {
restoreWorktreeArchiveState({ serverId, worktreePath, snapshot });
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
@@ -34,6 +34,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -23,6 +23,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -73,6 +74,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "running",
activityAt: "not-a-date",
diffStat: null,
@@ -105,6 +107,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
@@ -116,6 +119,27 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([]);
});
it("defaults missing archivingAt to null", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
} as unknown as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.archivingAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
@@ -126,6 +150,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
+2
View File
@@ -117,6 +117,7 @@ export interface WorkspaceDescriptor {
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
status: WorkspaceDescriptorPayload["status"];
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
@@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor(
workspaceKind: payload.workspaceKind,
name: payload.name,
status: payload.status,
archivingAt: payload.archivingAt ?? null,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
gitRuntime: payload.gitRuntime,
+1
View File
@@ -43,6 +43,7 @@ function workspace(input: {
workspaceKind: "local_checkout",
name: input.id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
@@ -23,6 +23,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
scripts: [],
hasRunningScripts: false,
...overrides,
archivingAt: overrides.archivingAt ?? null,
};
}
@@ -23,6 +23,7 @@ function workspace(input: {
workspaceKind: "checkout",
name: input.name,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
@@ -32,6 +32,7 @@ function workspace(
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -21,6 +21,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name ?? "main",
status: input.status ?? "running",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};