diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index b2f4999a1..9763d7168 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildHostRootRoute } from "@/utils/host-routes"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; export default function HostAgentReadyRoute() { return ( @@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() { if (resolvedWorkspaceId) { redirectedRef.current = true; - navigateToPreparedWorkspaceTab({ + navigateToAgent({ serverId, - workspaceId: resolvedWorkspaceId, - target: { kind: "agent", agentId }, + agentId, currentPathname: pathname, }); } @@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() { const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId); redirectedRef.current = true; if (workspaceId) { - navigateToPreparedWorkspaceTab({ + navigateToAgent({ serverId, + agentId, workspaceId, - target: { kind: "agent", agentId }, currentPathname: pathname, }); return; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2c5daced0..fda62a4a3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -328,6 +328,8 @@ export const ar: TranslationResources = { workspace: { route: { loading: "جارٍ تحميل مساحة العمل", + restoring: "جارٍ استعادة مساحة العمل", + restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه", connecting: "الاتصال", hostOffline: "{{hostName}}غير متواجد حالياً", cannotReachHost: "لا يمكن الوصول إلى{{hostName}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 0aa2c4b67..2e9cb0a09 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -327,6 +327,9 @@ export const en = { workspace: { route: { loading: "Loading workspace", + restoring: "Restoring workspace", + restoreFailed: + "Couldn't restore this workspace — the directory may have been moved or deleted", connecting: "Connecting", hostOffline: "{{hostName}} is offline", cannotReachHost: "Cannot reach {{hostName}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index a32cba7d1..603abd8d8 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -331,6 +331,9 @@ export const es: TranslationResources = { workspace: { route: { loading: "Cargando espacio de trabajo", + restoring: "Restaurando espacio de trabajo", + restoreFailed: + "No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado", connecting: "Conectando", hostOffline: "{{hostName}}está desconectado", cannotReachHost: "No se puede alcanzar{{hostName}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3273037b2..c69b51d1e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -331,6 +331,9 @@ export const fr: TranslationResources = { workspace: { route: { loading: "Chargement de l'espace de travail", + restoring: "Restauration de l'espace de travail", + restoreFailed: + "Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé", connecting: "De liaison", hostOffline: "{{hostName}}est hors ligne", cannotReachHost: "Impossible d'atteindre{{hostName}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1af3ba116..40312d252 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -330,6 +330,9 @@ export const ru: TranslationResources = { workspace: { route: { loading: "Загрузка рабочей области", + restoring: "Восстановление рабочей области", + restoreFailed: + "Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён", connecting: "Подключение", hostOffline: "{{hostName}}не в сети", cannotReachHost: "Невозможно связаться с{{hostName}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 497e0d235..d79614a15 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -328,6 +328,8 @@ export const zhCN: TranslationResources = { workspace: { route: { loading: "正在加载 workspace", + restoring: "正在恢复 workspace", + restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除", connecting: "正在连接", hostOffline: "{{hostName}} 已离线", cannotReachHost: "无法连接 {{hostName}}", diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index f4c364563..ba40dad12 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -21,6 +21,8 @@ export function renderWorkspaceRouteGate(input: { switch (input.state.kind) { case "loading": return ; + case "restoring": + return ; case "unreachable": return ( ); @@ -70,6 +73,21 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) { ); } +function WorkspaceRestoring({ hostName }: { hostName: string }) { + const { theme } = useUnistyles(); + const { t } = useTranslation(); + + return ( + + + + {t("workspace.route.restoring")} + {hostName} + + + ); +} + function WorkspaceUnreachable({ state, onRetry, @@ -124,13 +142,23 @@ function WorkspaceUnreachable({ ); } -function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) { +function WorkspaceMissing({ + hostName, + restoreFailed, + onDismiss, +}: { + hostName: string; + restoreFailed: boolean; + onDismiss: () => void; +}) { const { t } = useTranslation(); return ( - {t("workspace.route.missing")} + + {restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")} + {hostName} diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index dc4ea674d..48d893959 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => { lastError: "transport closed", workspace: null, hasHydratedWorkspaces: false, + restoreStatus: null, }), ).toEqual({ kind: "unreachable", @@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => { lastError: "transport closed", workspace: null, hasHydratedWorkspaces: true, + restoreStatus: null, }), ).toEqual({ kind: "unreachable", @@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => { lastError: "transport closed", workspace: createWorkspaceDescriptor(), hasHydratedWorkspaces: true, + restoreStatus: null, }), ).toEqual({ kind: "reconnecting", @@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => { lastError: null, workspace: null, hasHydratedWorkspaces: true, + restoreStatus: null, }), - ).toEqual({ kind: "missing", hostName: "Laptop" }); + ).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false }); }); it("returns loading before workspace hydration when the host is online", () => { @@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => { lastError: null, workspace: null, hasHydratedWorkspaces: false, + restoreStatus: null, }), ).toEqual({ kind: "loading", hostName: "Laptop" }); }); @@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => { lastError: null, workspace: createWorkspaceDescriptor(), hasHydratedWorkspaces: true, + restoreStatus: null, + }), + ).toEqual({ kind: "ready" }); + }); + + it("returns restoring while an archived workspace restore is in flight", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: null, + hasHydratedWorkspaces: true, + restoreStatus: "restoring", + }), + ).toEqual({ kind: "restoring", hostName: "Laptop" }); + }); + + it("falls back to a restore-failed missing state once the restore times out", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: null, + hasHydratedWorkspaces: true, + restoreStatus: "failed", + }), + ).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true }); + }); + + it("resolves to ready when the descriptor arrives even while restoring", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: createWorkspaceDescriptor(), + hasHydratedWorkspaces: true, + restoreStatus: "restoring", }), ).toEqual({ kind: "ready" }); }); diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts index 0c4e2ef89..0f689b2bd 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -16,7 +16,8 @@ export type WorkspaceRouteState = lastError: string | null; } | { kind: "loading"; hostName: string } - | { kind: "missing"; hostName: string }; + | { kind: "restoring"; hostName: string } + | { kind: "missing"; hostName: string; restoreFailed: boolean }; export function resolveWorkspaceRouteState(input: { hostName: string; @@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: { lastError: string | null; workspace: WorkspaceDescriptor | null; hasHydratedWorkspaces: boolean; + restoreStatus: "restoring" | "failed" | null; }): WorkspaceRouteState { if (input.workspace) { if (input.connectionStatus === "online") { @@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: { } if (input.connectionStatus === "online") { + if (input.restoreStatus === "restoring") { + return { kind: "restoring", hostName: input.hostName }; + } + if (input.hasHydratedWorkspaces) { - return { kind: "missing", hostName: input.hostName }; + return { + kind: "missing", + hostName: input.hostName, + restoreFailed: input.restoreStatus === "failed", + }; } return { kind: "loading", hostName: input.hostName }; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2de57ad9c..50896cf98 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -71,7 +71,11 @@ import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + useSessionStore, + useWorkspaceRestoreStatus, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey, collectAllTabs, @@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): { function useResolvedWorkspaceRouteState(input: { serverId: string; + workspaceId: string; workspace: WorkspaceDescriptor | null; hasHydratedWorkspaces: boolean; }): WorkspaceRouteState { @@ -1497,6 +1502,7 @@ function useResolvedWorkspaceRouteState(input: { ); const hostSnapshot = useHostRuntimeSnapshot(input.serverId); const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]); + const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId); return useMemo( () => @@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: { lastError: hostSnapshot?.lastError ?? null, workspace: input.workspace, hasHydratedWorkspaces: input.hasHydratedWorkspaces, + restoreStatus, }), [ hostName, @@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: { hostSnapshot?.lastError, input.workspace, input.hasHydratedWorkspaces, + restoreStatus, ], ); } @@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({ ); const workspaceRouteState = useResolvedWorkspaceRouteState({ serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, workspace: workspaceDescriptor, hasHydratedWorkspaces, }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 650f92168..7e7f78d32 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -363,6 +363,51 @@ describe("mergeWorkspaces", () => { expect(after.workspaces).not.toBe(before.workspaces); expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull(); }); + + it("clears a pending restore status when the matching descriptor lands", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring"); + expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe( + "restoring", + ); + + store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]); + + expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false); + }); +}); + +describe("setWorkspaceRestoreStatus", () => { + it("marks restoring then failed while the workspace is still absent", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring"); + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed"); + + expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed"); + }); + + it("ignores a late failed once the descriptor has landed (no-op)", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring"); + store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]); + + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed"); + + expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false); + }); + + it("ignores failed when no restore is in flight", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + + store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed"); + + expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false); + }); }); describe("setWorkspaces", () => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index e265d5643..a33b80792 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -287,6 +287,8 @@ export interface AgentTimelineCursorState { endSeq: number; } +export type WorkspaceRestoreStatus = "restoring" | "failed"; + // Per-session state export interface SessionState { serverId: string; @@ -332,6 +334,9 @@ export interface SessionState { // Project parents with no active workspaces, keyed by projectId. Rendered as // empty project rows in the sidebar. emptyProjects: Map; + // Transient restore state for archived workspaces, keyed by normalized + // workspaceId. Cleared in mergeWorkspaces when the descriptor lands. + restoringWorkspaces: Map; // Permissions pendingPermissions: Map; @@ -455,6 +460,12 @@ interface SessionStoreActions { removeWorkspace: (serverId: string, workspaceId: string) => void; setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void; addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; + setWorkspaceRestoreStatus: ( + serverId: string, + workspaceId: string, + status: WorkspaceRestoreStatus, + ) => void; + clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void; // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; @@ -527,6 +538,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess agentDetails: new Map(), workspaces: new Map(), emptyProjects: new Map(), + restoringWorkspaces: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), @@ -1253,6 +1265,54 @@ export const useSessionStore = create()( }); }, + setWorkspaceRestoreStatus: (serverId, workspaceId, status) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + if (session.restoringWorkspaces.get(workspaceId) === status) { + return prev; + } + // A late dir-gone timeout must not override a successful restore: + // only mark failed while still restoring and the descriptor is absent. + if ( + status === "failed" && + (session.restoringWorkspaces.get(workspaceId) !== "restoring" || + session.workspaces.has(workspaceId)) + ) { + return prev; + } + const next = new Map(session.restoringWorkspaces); + next.set(workspaceId, status); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, restoringWorkspaces: next }, + }, + }; + }); + }, + + clearWorkspaceRestoreStatus: (serverId, workspaceId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || !session.restoringWorkspaces.has(workspaceId)) { + return prev; + } + const next = new Map(session.restoringWorkspaces); + next.delete(workspaceId); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, restoringWorkspaces: next }, + }, + }; + }); + }, + mergeWorkspaces: (serverId, workspaces) => { const nextEntries = Array.from(workspaces); set((prev) => { @@ -1266,10 +1326,18 @@ export const useSessionStore = create()( // empty: prune any stale empty descriptor so it stops governing the // project's rendered metadata. const nextEmptyProjects = new Map(session.emptyProjects); + // A descriptor arriving is the success signal for a pending restore: + // clear it at the source so every entry point converges to "ready". + let nextRestoring: Map | null = null; for (const workspace of nextEntries) { if (nextEmptyProjects.delete(workspace.projectId)) { changed = true; } + if (session.restoringWorkspaces.has(workspace.id)) { + nextRestoring ??= new Map(session.restoringWorkspaces); + nextRestoring.delete(workspace.id); + changed = true; + } const existing = next.get(workspace.id); const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing); if (existing === nextWorkspace) { @@ -1285,7 +1353,12 @@ export const useSessionStore = create()( ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects }, + [serverId]: { + ...session, + workspaces: next, + emptyProjects: nextEmptyProjects, + restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces, + }, }, }; }); @@ -1485,3 +1558,14 @@ export const useSessionStore = create()( }; }), ); + +export function useWorkspaceRestoreStatus( + serverId: string | null, + workspaceId: string | null, +): WorkspaceRestoreStatus | null { + return useSessionStore((state) => + serverId && workspaceId + ? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null) + : null, + ); +} diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index f674dbf3e..e2a0b9233 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -1,10 +1,51 @@ import { router, type Href } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; +import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; export type { NavigateToAgentInput } from "./resolve"; +// Clears the transient restoring state if the daemon resolves refreshAgent without +// re-emitting a workspace_update (the directory-gone case), so the gate never spins forever. +const RESTORE_TIMEOUT_MS = 7000; + +function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void { + const snapshot = getHostRuntimeStore().getSnapshot(serverId); + const client = snapshot?.client ?? null; + if (!client || !isHostRuntimeConnected(snapshot)) { + return; + } + + const store = useSessionStore.getState(); + const session = store.sessions[serverId]; + // Self-gate: only an archived agent whose workspace is absent needs restoring. + // A still-present workspace or an in-flight restore is a no-op; fire-once is + // derived from store state. + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + if (!agent?.archivedAt) { + return; + } + if (session?.workspaces.has(workspaceId)) { + return; + } + if (session?.restoringWorkspaces.get(workspaceId) === "restoring") { + return; + } + + store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring"); + // The reducer guards "failed" so a late timeout after the descriptor lands is a no-op. + setTimeout( + () => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"), + RESTORE_TIMEOUT_MS, + ); + client + .refreshAgent(agentId) + .catch(() => + useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"), + ); +} + export function navigateToAgent(input: NavigateToAgentInput): string { return resolveNavigateToAgent(input, { readAgentNavTarget: ({ serverId, agentId }) => { @@ -18,5 +59,8 @@ export function navigateToAgent(input: NavigateToAgentInput): string { router.navigate(route as Href); }, navigateToPreparedWorkspaceTab, + restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => { + restoreArchivedWorkspace(serverId, agentId, workspaceId); + }, }); } 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 b3dd22123..e061ea050 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -16,16 +16,25 @@ interface RecordedHostNav { interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {} +interface RecordedRestore { + serverId: string; + agentId: string; + workspaceId: string; +} + function createFakeNavigators(target: AgentNavTarget): { deps: NavigateToAgentDeps; hostNavigations: RecordedHostNav[]; tabNavigations: RecordedTabNav[]; + restores: RecordedRestore[]; } { const hostNavigations: RecordedHostNav[] = []; const tabNavigations: RecordedTabNav[] = []; + const restores: RecordedRestore[] = []; return { hostNavigations, tabNavigations, + restores, deps: { readAgentNavTarget: () => target, navigateToHostAgent: (route) => { @@ -35,6 +44,9 @@ function createFakeNavigators(target: AgentNavTarget): { tabNavigations.push(input); return `/h/${input.serverId}/workspace/${input.workspaceId}`; }, + restoreArchivedWorkspace: (input) => { + restores.push(input); + }, }, }; } @@ -63,6 +75,55 @@ describe("resolveNavigateToAgent", () => { ]); }); + it("delegates a restore attempt whenever a workspaceId resolves", () => { + const { deps, restores, tabNavigations } = createFakeNavigators({ + agentWorkspaceId: WORKSPACE_ID, + }); + + resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps); + + expect(restores).toEqual([ + { serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID }, + ]); + expect(tabNavigations).toHaveLength(1); + }); + + it("uses the input workspaceId without reading the nav target", () => { + const readTargets: { serverId: string; agentId: string }[] = []; + const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null }); + deps.readAgentNavTarget = (input) => { + readTargets.push(input); + return { agentWorkspaceId: null }; + }; + + resolveNavigateToAgent( + { serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID }, + deps, + ); + + expect(readTargets).toEqual([]); + expect(tabNavigations).toEqual([ + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + currentPathname: undefined, + pin: undefined, + }, + ]); + }); + + it("does not trigger a restore when no workspaceId resolves", () => { + const { deps, restores, hostNavigations } = createFakeNavigators({ + agentWorkspaceId: null, + }); + + resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps); + + expect(restores).toEqual([]); + expect(hostNavigations).toHaveLength(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 a472a9d46..61188d4b0 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -5,6 +5,9 @@ import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-worksp export interface NavigateToAgentInput { serverId: string; agentId: string; + // Used as the workspace target when the agent is not yet in the session store + // (cold deep-links). Otherwise the workspace is read from the store. + workspaceId?: string | null; currentPathname?: string | null; pin?: boolean; } @@ -17,16 +20,20 @@ export interface NavigateToAgentDeps { readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; navigateToHostAgent: (route: string) => void; navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string; + restoreArchivedWorkspace: (input: { + serverId: string; + agentId: string; + workspaceId: string; + }) => void; } export function resolveNavigateToAgent( input: NavigateToAgentInput, deps: NavigateToAgentDeps, ): string { - const { agentWorkspaceId } = deps.readAgentNavTarget({ - serverId: input.serverId, - agentId: input.agentId, - }); + const agentWorkspaceId = + input.workspaceId ?? + deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId; const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); if (!workspaceId) { @@ -35,6 +42,14 @@ export function resolveNavigateToAgent( return route; } + // Restore self-gates on the agent being archived with its workspace absent, so + // ordinary navigations are a cheap no-op. + deps.restoreArchivedWorkspace({ + serverId: input.serverId, + agentId: input.agentId, + workspaceId, + }); + return deps.navigateToPreparedWorkspaceTab({ 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 new file mode 100644 index 000000000..098327165 --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; + +const refreshAgent = vi.fn<(agentId: string) => Promise>(); +let connected = true; + +vi.mock("expo-router", () => ({ + router: { navigate: vi.fn() }, +})); + +vi.mock("@/utils/workspace-navigation", () => ({ + navigateToPreparedWorkspaceTab: vi.fn(() => ""), +})); + +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => ({ + getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot, + }), + isHostRuntimeConnected: () => connected, +})); + +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { navigateToAgent } from "./index"; + +const SERVER_ID = "server-1"; +const AGENT_ID = "agent-1"; +const WORKSPACE_ID = "workspace-1"; + +function status(): "restoring" | "failed" | null { + return ( + useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null + ); +} + +function seedArchivedAgent(): void { + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.set(AGENT_ID, { + id: AGENT_ID, + workspaceId: WORKSPACE_ID, + archivedAt: new Date(), + } as unknown as Agent); + return next; + }); +} + +describe("restoreArchivedWorkspace via navigateToAgent", () => { + beforeEach(() => { + vi.useFakeTimers(); + refreshAgent.mockReset(); + connected = true; + seedArchivedAgent(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + useSessionStore.getState().clearSession(SERVER_ID); + }); + + function trigger(): void { + navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }); + } + + it("calls refreshAgent once and marks the workspace restoring", () => { + refreshAgent.mockImplementation(() => new Promise(() => {})); + + trigger(); + + expect(refreshAgent).toHaveBeenCalledTimes(1); + expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID); + expect(status()).toBe("restoring"); + }); + + it("does not re-fire while a restore for the same workspace is in flight", () => { + refreshAgent.mockImplementation(() => new Promise(() => {})); + + trigger(); + trigger(); + trigger(); + + expect(refreshAgent).toHaveBeenCalledTimes(1); + }); + + it("does not fire for a non-archived agent", () => { + const store = useSessionStore.getState(); + store.setAgents(SERVER_ID, (prev) => { + const next = new Map(prev); + next.set(AGENT_ID, { + id: AGENT_ID, + workspaceId: WORKSPACE_ID, + archivedAt: undefined, + } as unknown as Agent); + return next; + }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + trigger(); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBeNull(); + }); + + it("does not fire while disconnected", () => { + connected = false; + refreshAgent.mockImplementation(() => new Promise(() => {})); + + trigger(); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBeNull(); + }); + + it("flips to failed when refreshAgent rejects", async () => { + refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone"))); + + trigger(); + expect(status()).toBe("restoring"); + + await vi.runAllTicks(); + await Promise.resolve(); + + expect(status()).toBe("failed"); + }); + + it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => { + refreshAgent.mockImplementation(() => Promise.resolve({})); + + trigger(); + await Promise.resolve(); + expect(status()).toBe("restoring"); + + await vi.advanceTimersByTimeAsync(7000); + expect(status()).toBe("failed"); + }); + + 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: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }, + ]); + + trigger(); + + expect(refreshAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 1da1e5df1..e1ffbbd98 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -65,6 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/); expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); expect(result.workspace.displayName).toBe("feature-one"); + expect(result.workspace.baseBranch).toBe("main"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(events).toEqual([ "project:remote:github.com/acme/repo", @@ -425,6 +426,9 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename version: 1, baseRefName: "dev", }); + // A checkout-branch worktree has no distinct base, so the workspace records a + // null baseBranch even though worktree.json's baseRefName is the branch itself. + expect(created.workspace.baseBranch).toBe(null); await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 9be866f05..da2e9e373 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -21,6 +21,7 @@ import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, + normalizeBaseRefName, readPaseoWorktreeMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js"; @@ -71,6 +72,7 @@ export async function createPaseoWorktree( projectId: input.projectId, repoRoot: createdWorktree.repoRoot, worktree: createdWorktree.worktree, + baseBranch: resolveIntentBaseBranch(createdWorktree.intent), title: resolveFirstAgentPromptTitle(input.firstAgentContext), deps, }); @@ -197,11 +199,25 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: { }); } +// The base branch is normalized to match worktree.json's baseRefName (origin/ +// stripped). checkout-branch worktrees have no distinct base, so they stay null. +function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null { + switch (intent.kind) { + case "branch-off": + return normalizeBaseRefName(intent.baseBranch); + case "checkout-github-pr": + return normalizeBaseRefName(intent.baseRefName); + case "checkout-branch": + return null; + } +} + async function upsertWorkspaceForWorktree(options: { inputCwd: string; projectId?: string; repoRoot: string; worktree: WorktreeConfig; + baseBranch?: string | null; title?: string | null; deps: Pick< CreatePaseoWorktreeDeps, @@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: { kind: "worktree", displayName: options.worktree.branchName || normalizedCwd, branch: options.worktree.branchName || null, + baseBranch: options.baseBranch ?? null, title: options.title ?? null, createdAt: now, updatedAt: now, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b0548fd81..827922dc7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3503,6 +3503,7 @@ export class Session { try { await unarchiveAgentState(this.agentStorage, this.agentManager, agentId); + await this.unarchiveOwningWorkspaceForAgent(agentId); let snapshot: ManagedAgent; const existing = this.agentManager.getAgent(agentId); if (existing) { @@ -7066,6 +7067,23 @@ export class Session { }; } + private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise { + const record = await this.agentStorage.get(agentId); + if (!record?.workspaceId) { + return; + } + const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false); + if (!directoryExists) { + return; + } + const workspace = await this.workspaceRegistry.get(record.workspaceId); + if (!workspace?.archivedAt) { + return; + } + await this.ensureWorkspaceRecordUnarchived(workspace); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); + } + private async ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 33467e501..d93515d10 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -83,6 +83,8 @@ interface SessionTestAccess { }; agentManager: { listAgents(): unknown[]; + getAgent(agentId: string): unknown; + reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise; listImportableSessions(options?: unknown): Promise; importProviderSession(input: unknown): Promise; resumeAgentFromPersistence( @@ -147,6 +149,9 @@ interface SessionTestAccess { peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null; registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void }; }; + filesystem: { + isDirectory(cwd: string): Promise; + }; } interface ListFetchResult { @@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project expect(response?.payload.workspace?.id).toBe(workspaceId); }); +test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = path.resolve("/tmp/paseo-unit2-existing-dir"); + session.filesystem.isDirectory = async () => true; + const workspaceId = "ws-repo-archived"; + const agentId = "agent-archived"; + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: cwd, + cwd, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + const storedAgent: StoredAgentRecord = { + ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }), + workspaceId, + archivedAt: "2026-03-10T00:00:00.000Z", + }; + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); + session.agentStorage.upsert = async () => {}; + + const managed = makeManagedAgent({ + id: agentId, + cwd, + workspaceId, + lifecycle: "idle", + updatedAt: "2026-03-10T00:00:00.000Z", + }); + session.agentManager.getAgent = () => managed; + session.interruptAgentIfRunning = async () => undefined; + session.agentManager.reloadAgentSession = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.forwardAgentUpdate = async () => undefined; + + const unarchivedWorkspaceIds: string[][] = []; + const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session); + session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => { + unarchivedWorkspaceIds.push(ids); + return realEmit(ids, ...rest); + }; + + await session.handleMessage({ + type: "refresh_agent_request", + agentId, + requestId: "req-refresh-unarchive", + }); + + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); + expect(projects.get(cwd)?.archivedAt).toBeNull(); + expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]); + expect(findByType(emitted, "rpc_error")).toBeUndefined(); +}); + +test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = path.resolve("/tmp/paseo-missing-workspace-dir"); + session.filesystem.isDirectory = async () => false; + const workspaceId = "ws-missing-dir"; + const agentId = "agent-missing-dir"; + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "non_git", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: cwd, + cwd, + kind: "directory", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + const storedAgent: StoredAgentRecord = { + ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }), + workspaceId, + archivedAt: "2026-03-10T00:00:00.000Z", + }; + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); + session.agentStorage.upsert = async () => {}; + + const managed = makeManagedAgent({ + id: agentId, + cwd, + workspaceId, + lifecycle: "idle", + updatedAt: "2026-03-10T00:00:00.000Z", + }); + session.agentManager.getAgent = () => managed; + session.interruptAgentIfRunning = async () => undefined; + session.agentManager.reloadAgentSession = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.forwardAgentUpdate = async () => undefined; + + await session.handleMessage({ + type: "refresh_agent_request", + agentId, + requestId: "req-refresh-missing-dir", + }); + + expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); + expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); +}); + test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index b8249d50c..b2d79801d 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + // The base branch the worktree was created from (normalized like worktree.json's + // baseRefName). Only worktree workspaces carry a base branch; checkout-branch + // worktrees and directory/local_checkout workspaces leave it null. + baseBranch: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: { displayName: string; title?: string | null; branch?: string | null; + baseBranch?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: { ...input, title: input.title ?? null, branch: input.branch ?? null, + baseBranch: input.baseBranch ?? null, archivedAt: input.archivedAt ?? null, }); }