diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index e297dfb769..ff596e2cc2 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -130,6 +130,18 @@ test.describe("Worktree restore", () => { await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + await openSessions(page); + await expectSessionRowNotArchived(page, agent.title); + await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); + + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`), + ).toHaveCount(1); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); }); test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({ diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 868dc13af4..68d68da9ca 100644 --- a/packages/app/src/navigation/workspace-route-navigation.test.ts +++ b/packages/app/src/navigation/workspace-route-navigation.test.ts @@ -77,4 +77,32 @@ describe("navigateToHostWorkspaceRoute", () => { }, }); }); + + it("preserves a workspace open intent in the POP_TO target", () => { + const { navigationRef, dispatch } = createNavigationRef({ + key: "root-stack", + routes: [{ key: "host-server-1", name: "h/[serverId]" }], + }); + registerWorkspaceRouteNavigationRef(navigationRef); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"); + + expect(dispatch).toHaveBeenCalledWith({ + type: "POP_TO", + target: "root-stack", + payload: { + name: "h/[serverId]", + params: { + serverId: "server-1", + screen: "workspace/[workspaceId]/index", + params: { + serverId: "server-1", + workspaceId: "workspace-a", + open: "agent:agent-1", + }, + pop: true, + }, + }, + }); + }); }); diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index a717f157be..6a60e9cd73 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -2,6 +2,7 @@ import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react import { router, type Href } from "expo-router"; import { encodeWorkspaceIdForPathSegment, + getHostWorkspaceOpenParamFromPathname, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; @@ -82,6 +83,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean { if (!target) { return false; } + const open = getHostWorkspaceOpenParamFromPathname(route); const action: NavigationAction = { type: "POP_TO", @@ -94,6 +96,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean { params: { serverId: selection.serverId, workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId), + ...(open ? { open } : {}), }, // React Navigation consumes this nested hint when resolving the host child screen. // The browser-route canonicalizer strips the resulting ?pop=true URL artifact. diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index b3358b3a14..6cc8191936 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -11,8 +11,8 @@ import { navigateToLastWorkspace as navigateToLastWorkspacePure, navigateToWorkspace as navigateToWorkspacePure, parseActiveWorkspaceSelection, - type NavigateToWorkspaceInput, type NavigateToWorkspaceDeps, + type NavigateToWorkspaceInput, } from "./navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 189340cbbc..717bc82967 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent( export function parseHostWorkspaceOpenIntentFromPathname( pathname: string, ): WorkspaceOpenIntent | null { + return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname)); +} + +export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null { const search = extractSearch(pathname); if (!search) { return null; } - return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open")); + const open = new URLSearchParams(search).get("open"); + return parseWorkspaceOpenIntent(open) ? open : null; } export function encodeWorkspaceIdForPathSegment(workspaceId: string): string { diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 1fd118b5bd..8ccb75d68d 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -1,7 +1,7 @@ import { router, type Href } from "expo-router"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; export type { NavigateToAgentInput } from "./resolve"; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index 67be3a6837..d91aa3ce6e 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -55,13 +55,8 @@ export function useWorkspaceRecovery(input: { throw new Error("The host disconnected before the workspace could be recovered."); } await waitForRecoveryLoadingPresentation(); - const [result] = await Promise.allSettled([ - client.restoreWorkspace(input.workspaceId), - waitForMinimumRecoveryLoadingTime(), - ]); - if (result.status === "rejected") { - throw result.reason; - } + await waitForMinimumRecoveryLoadingTime(); + await client.restoreWorkspace(input.workspaceId); }, }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2d99d4ef4d..6d6ee22208 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1494,14 +1494,7 @@ export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("unavailable"), workspaceId: z.string(), - reason: z.enum([ - "workspace_not_found", - "workspace_not_archived", - "project_not_found", - "project_directory_missing", - "workspace_directory_missing", - "worktree_branch_missing", - ]), + reason: z.string(), message: z.string(), }), ]); diff --git a/packages/protocol/src/messages.workspace-recovery.test.ts b/packages/protocol/src/messages.workspace-recovery.test.ts index b910698f7c..56c3ac44c6 100644 --- a/packages/protocol/src/messages.workspace-recovery.test.ts +++ b/packages/protocol/src/messages.workspace-recovery.test.ts @@ -83,4 +83,28 @@ describe("workspace recovery protocol", () => { }).features?.workspaceRecovery, ).toBe(true); }); + + test("accepts unavailable reasons added by newer daemons", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.recovery.inspect.response", + payload: { + requestId: "inspect-future", + state: { + kind: "unavailable", + workspaceId: "workspace-1", + reason: "future_recovery_constraint", + message: "This host cannot recover the workspace yet.", + }, + }, + }), + ).toMatchObject({ + payload: { + state: { + kind: "unavailable", + reason: "future_recovery_constraint", + }, + }, + }); + }); });