diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 601c3626de..7ec6692791 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -2,7 +2,11 @@ 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 { + resolveNavigateToAgent, + type HistoryRestoreTarget, + type NavigateToAgentInput, +} from "./resolve"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; export type { NavigateToAgentInput } from "./resolve"; @@ -13,34 +17,31 @@ 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 { - target: HistoryWorkspaceRestore; +interface PendingHistoryRestore { + target: HistoryRestoreTarget; unsubscribe: () => void; } -interface HistoryWorkspaceRestore { - serverId: string; - agentId: string; - workspaceId: string; - agentArchived: boolean; -} +const pendingHydrationRestores = new Map(); +const pendingAgentReopens = new Map(); -const pendingWorkspaceRestores = new Map(); - -function restoreWhenWorkspacesHydrate(target: HistoryWorkspaceRestore): void { +function restoreWhenWorkspacesHydrate(target: HistoryRestoreTarget): void { const { serverId, workspaceId } = target; + if (!workspaceId) { + return; + } const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); if (!key) { return; } - const pending = pendingWorkspaceRestores.get(key); + const pending = pendingHydrationRestores.get(key); if (pending) { pending.target = target; return; } - const restore: PendingWorkspaceRestore = { + const restore: PendingHistoryRestore = { target, unsubscribe: () => {}, }; @@ -51,15 +52,50 @@ function restoreWhenWorkspacesHydrate(target: HistoryWorkspaceRestore): void { } restore.unsubscribe(); - pendingWorkspaceRestores.delete(key); + pendingHydrationRestores.delete(key); if (session) { - restoreArchivedWorkspace(restore.target); + restoreHistoryEntry(restore.target); } }); - pendingWorkspaceRestores.set(key, restore); + pendingHydrationRestores.set(key, restore); } -function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void { +function reopenAgentAfterWorkspaceRestore(target: HistoryRestoreTarget): void { + const { serverId, workspaceId } = target; + if (!workspaceId) { + return; + } + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!key) { + return; + } + + const pending = pendingAgentReopens.get(key); + if (pending) { + pending.target = target; + return; + } + + const restore: PendingHistoryRestore = { + target, + unsubscribe: () => {}, + }; + restore.unsubscribe = useSessionStore.subscribe((state) => { + const session = state.sessions[serverId]; + if (session?.restoringWorkspaces.get(workspaceId) === "restoring") { + return; + } + + restore.unsubscribe(); + pendingAgentReopens.delete(key); + if (session?.workspaces.has(workspaceId)) { + restoreHistoryEntry(restore.target); + } + }); + pendingAgentReopens.set(key, restore); +} + +function restoreHistoryEntry(target: HistoryRestoreTarget): void { const { serverId, agentId, workspaceId, agentArchived } = target; const snapshot = getHostRuntimeStore().getSnapshot(serverId); const client = snapshot?.client ?? null; @@ -72,7 +108,24 @@ function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void { if (!session) { return; } + const liveAgent = session.agents.get(agentId) ?? session.agentDetails.get(agentId); + const shouldReopenAgent = agentArchived && (!liveAgent || Boolean(liveAgent.archivedAt)); + if (!workspaceId) { + if (shouldReopenAgent) { + client.refreshAgent(agentId).catch((error) => { + console.error("[HistoryRestore] Failed to reopen archived agent", { + serverId, + agentId, + error, + }); + }); + } + return; + } if (session.restoringWorkspaces.get(workspaceId) === "restoring") { + if (shouldReopenAgent) { + reopenAgentAfterWorkspaceRestore(target); + } return; } // An empty workspace map is not authoritative during startup. Keep the route on @@ -87,8 +140,6 @@ function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void { // must restore even when its agent survived unarchived; an archived agent must // still reopen when its workspace survived. if (session.workspaces.has(workspaceId)) { - const liveAgent = session.agents.get(agentId) ?? session.agentDetails.get(agentId); - const shouldReopenAgent = agentArchived && (!liveAgent || Boolean(liveAgent.archivedAt)); if (shouldReopenAgent) { client.refreshAgent(agentId).catch((error) => { console.error("[HistoryRestore] Failed to reopen archived agent", { @@ -136,6 +187,6 @@ export function navigateToAgent(input: NavigateToAgentInput): string { router.navigate(route as Href); }, navigateToWorkspace, - restoreArchivedWorkspace, + restoreHistoryEntry, }); } diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index be99654cb8..e003cbb2da 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -19,7 +19,7 @@ interface RecordedTabNav extends NavigateToWorkspaceInput {} interface RecordedRestore { serverId: string; agentId: string; - workspaceId: string; + workspaceId: string | null; agentArchived: boolean; } @@ -45,7 +45,7 @@ function createFakeNavigators(target: AgentNavTarget): { tabNavigations.push(input); return `/h/${input.serverId}/workspace/${input.workspaceId}`; }, - restoreArchivedWorkspace: (input) => { + restoreHistoryEntry: (input) => { restores.push(input); }, }, @@ -147,6 +147,31 @@ describe("resolveNavigateToAgent", () => { expect(hostNavigations).toHaveLength(1); }); + it("reopens an archived History agent even when it has no workspaceId", () => { + const { deps, restores, hostNavigations } = createFakeNavigators({ + agentWorkspaceId: null, + }); + + resolveNavigateToAgent( + { + serverId: SERVER_ID, + agentId: AGENT_ID, + restoreWorkspace: { agentArchived: true }, + }, + deps, + ); + + expect(restores).toEqual([ + { + serverId: SERVER_ID, + agentId: AGENT_ID, + workspaceId: null, + agentArchived: true, + }, + ]); + expect(hostNavigations).toEqual([{ route: "/h/server-1/agent/agent-1" }]); + }); + it("falls back to the host agent route when the agent has no workspaceId", () => { const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null, diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index efd5799b6c..da14436ef7 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -21,16 +21,18 @@ export interface AgentNavTarget { agentWorkspaceId: string | null | undefined; } +export interface HistoryRestoreTarget { + serverId: string; + agentId: string; + workspaceId: string | null; + agentArchived: boolean; +} + export interface NavigateToAgentDeps { readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; navigateToHostAgent: (route: string) => void; navigateToWorkspace: (input: NavigateToWorkspaceInput) => string; - restoreArchivedWorkspace: (input: { - serverId: string; - agentId: string; - workspaceId: string; - agentArchived: boolean; - }) => void; + restoreHistoryEntry: (input: HistoryRestoreTarget) => void; } export function resolveNavigateToAgent( @@ -42,14 +44,8 @@ export function resolveNavigateToAgent( deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId; const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); - if (!workspaceId) { - const route = buildHostAgentDetailRoute(input.serverId, input.agentId); - deps.navigateToHostAgent(route); - return route; - } - if (input.restoreWorkspace) { - deps.restoreArchivedWorkspace({ + deps.restoreHistoryEntry({ serverId: input.serverId, agentId: input.agentId, workspaceId, @@ -57,6 +53,12 @@ export function resolveNavigateToAgent( }); } + if (!workspaceId) { + const route = buildHostAgentDetailRoute(input.serverId, input.agentId); + deps.navigateToHostAgent(route); + return route; + } + return deps.navigateToWorkspace({ serverId: input.serverId, workspaceId, 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 2b6d447e6d..f0bc575aa4 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 @@ -25,14 +25,15 @@ import { navigateToAgent } from "./index"; const SERVER_ID = "remote-server"; const AGENT_ID = "00000000-0000-4000-8000-000000000001"; +const SECOND_AGENT_ID = "00000000-0000-4000-8000-000000000002"; const WORKSPACE_ID = "wks_history_restore"; const MISSING_WORKTREE_CWD = "/home/tester/.paseo/worktrees/missing-history-worktree"; -function agent(archivedAt: Date | null): Agent { +function agent(archivedAt: Date | null, id = AGENT_ID): Agent { const createdAt = new Date("2026-01-01T00:00:00.000Z"); return { serverId: SERVER_ID, - id: AGENT_ID, + id, provider: "codex", status: "idle", createdAt, @@ -117,9 +118,13 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { }); function openFromHistory(entry = { agentArchived: true }): void { + openHistoryAgent(AGENT_ID, entry); + } + + function openHistoryAgent(agentId: string, entry = { agentArchived: true }): void { const input = { serverId: SERVER_ID, - agentId: AGENT_ID, + agentId, workspaceId: WORKSPACE_ID, restoreWorkspace: entry, }; @@ -150,6 +155,25 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { expect(refreshAgent).toHaveBeenCalledTimes(1); }); + it("reopens the selected archived agent after another agent restores its workspace", () => { + const store = useSessionStore.getState(); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.set(SECOND_AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z"), SECOND_AGENT_ID)); + return next; + }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + openFromHistory(); + openHistoryAgent(SECOND_AGENT_ID); + + expect(refreshAgent.mock.calls).toEqual([[AGENT_ID]]); + + store.mergeWorkspaces(SERVER_ID, [workspace()]); + + expect(refreshAgent.mock.calls).toEqual([[AGENT_ID], [SECOND_AGENT_ID]]); + }); + it("restores a History-opened closed agent whose archived workspace is missing", () => { const store = useSessionStore.getState(); store.setAgents(SERVER_ID, (prev) => { @@ -166,6 +190,26 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { expect(status()).toBe("restoring"); }); + it("reopens an archived History agent without a workspace identity", () => { + const store = useSessionStore.getState(); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.delete(AGENT_ID); + return next; + }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + navigateToAgent({ + serverId: SERVER_ID, + agentId: AGENT_ID, + restoreWorkspace: { agentArchived: true }, + }); + + expect(refreshAgent).toHaveBeenCalledTimes(1); + expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID); + expect(status()).toBeNull(); + }); + it("does not restore during ordinary active-workspace navigation", () => { const store = useSessionStore.getState(); store.setAgents(SERVER_ID, (prev) => {