Fix worktree creation and archive flow

Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.

App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
This commit is contained in:
Mohamed Boudra
2026-05-03 23:22:54 +07:00
parent c534c857a4
commit 7c85341a42
29 changed files with 733 additions and 229 deletions
@@ -104,9 +104,14 @@ import {
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): 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;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return snapshot;
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: WorkspaceDescriptor | null;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
}
}
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
toast.error(message);
});
})();
}, [
archiveWorktree,
isArchiving,
redirectAfterArchive,
toast,
workspace.name,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
return (
<>
@@ -2120,13 +2144,28 @@ function ProjectBlock({
}
setIsRemovingProject(true);
const snapshots = new Map(
project.workspaces.map((workspace) => [
workspace.workspaceId,
hideWorkspaceOptimistically(workspace),
]),
);
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
void Promise.allSettled(
project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
try {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId,
workspaceId: ws.workspaceId,
snapshot: snapshots.get(ws.workspaceId) ?? null,
});
throw error;
}
}),
).then((results) => {
+12 -9
View File
@@ -42,7 +42,6 @@ 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";
@@ -60,7 +59,10 @@ 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 {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
}
@@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: String(message.payload.id),
});
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
removeWorkspace(serverId, String(message.payload.id));
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId,
workspace,
isArchivePending: isLocalWorktreeArchivePending,
})
) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
return;
}
mergeWorkspaces(serverId, [workspace]);
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
markWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
id: "/repo/worktree",
@@ -21,40 +26,83 @@ 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);
describe("workspace archive pending suppression", () => {
it("tracks a locally pending workspace archive by id and directory", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
isArchivePending,
workspaceId: "/repo/worktree",
}),
).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({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace(),
isArchivePending: () => false,
workspaceDirectory: "/repo/worktree",
}),
).toBe(false);
});
it("allows normal upserts while a local archive is pending", () => {
).toBe(true);
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
isArchivePending: () => true,
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
expect(
isWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
}),
).toBe(false);
});
it("suppresses upserts for a locally pending archive", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
it("allows upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace(),
}),
).toBe(false);
});
it("suppresses stale normal upserts while a local archive is pending", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
});
@@ -1,15 +1,96 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
}
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
export function clearWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return;
}
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
if (archives.size === 0) {
pendingWorkspaceArchivesByServer.delete(serverId);
}
}
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(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,
})
);
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
@@ -11,6 +11,7 @@ import {
} from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
@@ -10,6 +10,10 @@ import {
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => {
deferred.resolve({});
await archive;
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(true);
});
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: "ws-feature",
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
deferred.resolve({});
await archive;
});
it("restores an optimistically hidden worktree when archive fails", async () => {
@@ -8,6 +8,14 @@ import {
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
const SUCCESS_DISPLAY_MS = 1000;
@@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
}) ?? input.worktreePath;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
return {
workspace:
useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ??
null,
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
predicate: (query) =>
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
@@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
markWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
});
removeWorktreeFromCachedLists({ serverId, worktreePath });
removeWorktreeFromSessionStore({ serverId, worktreePath });
removeWorktreeFromSessionStore({
serverId,
worktreePath: snapshot.workspace?.id ?? worktreePath,
});
try {
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
clearWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
});
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
+7 -2
View File
@@ -23,6 +23,7 @@ import type {
WorkspaceDescriptorPayload,
} from "@server/shared/messages";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
@@ -1114,11 +1115,15 @@ export const useSessionStore = create<SessionStore>()(
removeWorkspace: (serverId, workspaceId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || !session.workspaces.has(workspaceId)) {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: session?.workspaces,
workspaceId,
});
if (!session || !workspaceKey) {
return prev;
}
const next = new Map(session.workspaces);
next.delete(workspaceId);
next.delete(workspaceKey);
return {
...prev,
sessions: {