Key workspace status and ownership by ID, not directory

Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
This commit is contained in:
Mohamed Boudra
2026-06-16 13:37:58 +07:00
parent ba9f7740a8
commit 9966e49329
102 changed files with 3222 additions and 1932 deletions
+4 -2
View File
@@ -314,7 +314,7 @@ describe("checkout-git-actions-store", () => {
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
@@ -371,7 +371,9 @@ describe("checkout-git-actions-store", () => {
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
+7 -14
View File
@@ -13,10 +13,7 @@ import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -155,15 +152,11 @@ function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): b
function snapshotWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
workspaceId: string | undefined;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
});
const workspaceKey = workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
const workspaceKey = input.workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
: null;
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
@@ -506,16 +499,16 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
// The server archive is keyed by worktreePath and must always run. The
// optimistic client-side updates are keyed by workspace id, so they only
// apply when the workspace is resolved in the local store.
// apply when the caller passes the workspace id and it resolves in the
// local store.
const workspace = snapshot.workspace;
if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}
+25 -22
View File
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
@@ -15,9 +14,8 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { buildHostRootRoute } from "@/utils/host-routes";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
@@ -61,6 +59,9 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string):
function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean {
let references = 0;
for (const candidate of workspaces) {
// Git-fact: counting how many workspaces still reference the worktree
// directory on disk, not attributing ownership. Same-cwd siblings keep the
// directory alive, so the disk-deletion offer must compare directories.
if (candidate.workspaceDirectory === worktreePath) {
references += 1;
}
@@ -227,6 +228,7 @@ interface UseGitActionsResult {
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const { t } = useTranslation();
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
@@ -533,27 +535,25 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runArchiveWorktreeRecord = useCallback(
(worktreePath: string, deleteWorktreeFromDisk: boolean) => {
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaceList,
workspaceDirectory: worktreePath,
});
const redirectRoute = archivedWorkspaceId
? buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaceList,
})
: buildHostRootRoute(serverId);
router.replace(redirectRoute as Href);
// These git actions only ever target the workspace the screen is showing,
// so the redirect is sourced from the active workspace selection (the
// screen's own route context), not from any cwd→workspace inference here.
if (activeWorkspaceSelection) {
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
}
// The server archive is keyed by worktreePath; the daemon owns the
// directory→workspace resolution for archive-by-path.
void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch(
(err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
},
);
},
[cwd, runArchiveWorktree, serverId, t, toastActionError],
[activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError],
);
const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord);
@@ -567,9 +567,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const workspace = workspaceList.find(
(candidate) => candidate.workspaceDirectory === worktreePath,
);
// Git-fact lookup: find the workspace backing this worktree directory to show
// its name and diff stat in the confirmation. This reads display data by
// directory, the same as the disk-deletion reference counting, and never
// derives an id used to key the archive (the archive runs by path).
const workspace =
workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null;
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace?.name ?? branchLabel,