diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index dd69f67d26..faf1afb042 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -1,5 +1,6 @@ import { router, type Href } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -12,6 +13,48 @@ export type { NavigateToAgentInput } from "./resolve"; // to avoid flashing a false "failed" on a capable daemon doing slow real work. const RESTORE_TIMEOUT_MS = 30000; +interface PendingWorkspaceRestore { + agentId: string; + unsubscribe: () => void; +} + +const pendingWorkspaceRestores = new Map(); + +function restoreWhenWorkspacesHydrate( + serverId: string, + agentId: string, + workspaceId: string, +): void { + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!key) { + return; + } + + const pending = pendingWorkspaceRestores.get(key); + if (pending) { + pending.agentId = agentId; + return; + } + + const restore: PendingWorkspaceRestore = { + agentId, + unsubscribe: () => {}, + }; + restore.unsubscribe = useSessionStore.subscribe((state) => { + const session = state.sessions[serverId]; + if (session && !session.hasHydratedWorkspaces) { + return; + } + + restore.unsubscribe(); + pendingWorkspaceRestores.delete(key); + if (session) { + restoreArchivedWorkspace(serverId, restore.agentId, workspaceId); + } + }); + pendingWorkspaceRestores.set(key, restore); +} + function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void { const snapshot = getHostRuntimeStore().getSnapshot(serverId); const client = snapshot?.client ?? null; @@ -24,10 +67,17 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId // History carries restore intent explicitly. Workspace lifecycle is separate // from agent lifecycle, so a closed non-archived agent can legitimately own an // archived workspace. A present workspace or in-flight restore stays a no-op. - if (session?.workspaces.has(workspaceId)) { + if (!session || session.workspaces.has(workspaceId)) { return; } - if (session?.restoringWorkspaces.get(workspaceId) === "restoring") { + if (session.restoringWorkspaces.get(workspaceId) === "restoring") { + return; + } + // An empty workspace map is not authoritative during startup. Keep the route on + // its existing loading state, then decide whether restoration is needed once the + // daemon's workspace snapshot has landed. + if (!session.hasHydratedWorkspaces) { + restoreWhenWorkspacesHydrate(serverId, agentId, workspaceId); return; } @@ -35,7 +85,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId // Single capability read for restore. An old daemon recreates nothing on // refresh_agent, so a gone directory would spin then flash a misleading // "couldn't restore". Surface an explicit "update your host" state instead. - if (session?.serverInfo?.features?.worktreeRestore !== true) { + if (session.serverInfo?.features?.worktreeRestore !== true) { store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade"); return; } diff --git a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts index 2ed6c9143d..845a330e6a 100644 --- a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts +++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts @@ -20,7 +20,7 @@ vi.mock("@/runtime/host-runtime", () => ({ isHostRuntimeConnected: () => connected, })); -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; import { navigateToAgent } from "./index"; const SERVER_ID = "remote-server"; @@ -81,6 +81,25 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void { next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z"))); return next; }); + store.setHasHydratedWorkspaces(SERVER_ID, true); +} + +function workspace(): WorkspaceDescriptor { + return { + id: WORKSPACE_ID, + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: MISSING_WORKTREE_CWD, + workspaceDirectory: MISSING_WORKTREE_CWD, + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; } describe("restoreArchivedWorkspace via navigateToAgent", () => { @@ -162,6 +181,50 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { expect(status()).toBeNull(); }); + it("waits for workspace hydration before deciding a running History workspace is missing", () => { + const store = useSessionStore.getState(); + store.setHasHydratedWorkspaces(SERVER_ID, false); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.set(AGENT_ID, { ...agent(null), status: "running" }); + return next; + }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + openFromHistory(); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBeNull(); + + store.mergeWorkspaces(SERVER_ID, [workspace()]); + store.setHasHydratedWorkspaces(SERVER_ID, true); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBeNull(); + }); + + it("restores a missing History workspace after workspace hydration settles", () => { + const store = useSessionStore.getState(); + store.setHasHydratedWorkspaces(SERVER_ID, false); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.set(AGENT_ID, { ...agent(null), status: "closed" }); + return next; + }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + openFromHistory(); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBeNull(); + + store.setHasHydratedWorkspaces(SERVER_ID, true); + + expect(refreshAgent).toHaveBeenCalledTimes(1); + expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID); + expect(status()).toBe("restoring"); + }); + it("does not fire while disconnected", () => { connected = false; refreshAgent.mockImplementation(() => new Promise(() => {})); @@ -208,23 +271,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { it("is a no-op when the workspace descriptor is already present", () => { refreshAgent.mockImplementation(() => new Promise(() => {})); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [ - { - id: WORKSPACE_ID, - projectId: "project-1", - projectDisplayName: "Project 1", - projectRootPath: MISSING_WORKTREE_CWD, - workspaceDirectory: MISSING_WORKTREE_CWD, - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - statusEnteredAt: null, - archivingAt: null, - diffStat: null, - scripts: [], - }, - ]); + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspace()]); openFromHistory();