diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index a6778364e..63d812325 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -52,6 +52,12 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/ Cascade is what keeps subagent fleets from outliving their orchestrator. +Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving +agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the +archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt` +or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; +only the route's explicit Unarchive or Restore action changes the archived workspace. + ## Tabs vs archive These are two distinct concepts that used to be conflated: diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 42583e46f..3daaaef53 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -15,7 +15,6 @@ import { expectWorkspaceArchiveOutcome, expectWorkspaceTabHidden, fetchAgentArchivedAt, - expectWorkspaceTabVisible, openSessions, openWorkspaceWithAgents, primeAdditionalPage, @@ -123,7 +122,7 @@ test.describe("Archive tab reconciliation", () => { } }); - test("clicking an archived session unarchives it and opens the agent", async ({ page }) => { + test("clicking an archived session navigates without unarchiving it", async ({ page }) => { const archived = await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, @@ -138,18 +137,17 @@ test.describe("Archive tab reconciliation", () => { await resetSeededPageState(page); await openWorkspaceWithAgents(page, [archived, surviving]); await archiveAgentFromDaemon(client, archived.id); + const archivedAt = await fetchAgentArchivedAt(client, archived.id); + expect(archivedAt).not.toBeNull(); await openSessions(page); await expectSessionRowArchived(page, archived.title); await clickSessionRow(page, archived.title); - await expect - .poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 }) - .toBeNull(); + expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt); await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { timeout: 30_000, }); - await expectWorkspaceTabVisible(page, archived.id); }); }); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 05ae4d42f..979768c68 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -103,12 +103,6 @@ export async function fetchAgentArchivedAt( return result?.agent.archivedAt ?? null; } -export function getWorktreeRestoreFeature(client: { - getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null; -}): boolean { - return client.getLastServerInfoMessage()?.features?.worktreeRestore === true; -} - export async function primeAdditionalPage(page: Page): Promise { const seedNonce = randomUUID(); const { daemon, preferences } = buildSeededStoragePayload(); diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index b907017f1..13c267ab3 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick< | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" + | "inspectWorkspaceRecovery" + | "on" | "patchDaemonConfig" | "removeProject" >; diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/helpers/schedule-fake-host.ts index 3f4946801..49746b495 100644 --- a/packages/app/e2e/helpers/schedule-fake-host.ts +++ b/packages/app/e2e/helpers/schedule-fake-host.ts @@ -156,7 +156,7 @@ export async function installFakeScheduleHost(input: { workspaceMultiplicity: true, projectAdd: true, projectRemove: true, - worktreeRestore: true, + workspaceRecovery: true, }, }), ); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 3cb5abdfe..6a04bbd51 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -132,11 +132,15 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + refreshAgent(agentId: string): Promise; fetchAgent(options: { agentId: string; }): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; getLastServerInfoMessage(): { - features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null; + features?: { + projectAdd?: boolean; + workspaceRecovery?: boolean; + } | null; } | null; fetchAgentHistory(options?: { page?: { limit: number }; diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 8d17df503..3ab56dd5c 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -78,13 +78,8 @@ export async function navigateToTerminal( { timeout: 30_000 }, ); - // Wait for daemon connection (sidebar shows host label) - await page - .getByText("localhost", { exact: true }) - .first() - .waitFor({ state: "visible", timeout: 30_000 }); - // The open intent should have prepared and focused the exact pre-created terminal tab. + // Its presence is the user-visible proof that workspace and terminal state have hydrated. // The tab reconciliation effect also auto-creates terminal tabs once hydration completes, // so we give it enough time for the full workspace hydration + tab creation cycle. const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`); diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index b0f184d30..4497b568e 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -1,16 +1,25 @@ import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { rename } from "node:fs/promises"; +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { - archiveAgentFromDaemon, + expectWorkspaceBranch, + openChangesPanel, + switchBranchFromChangesPanel, +} from "./helpers/branch-switcher"; +import { createIdleAgent, - expectSessionRowArchived, + expectSessionRowNotArchived, fetchAgentArchivedAt, openSessions, } from "./helpers/archive-tab"; import { archiveWorkspaceFromDaemon, + archiveLocalWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, @@ -35,6 +44,63 @@ test.describe("Worktree restore", () => { tempRepo = await createTempGitRepo("wt-restore-"); }); + async function createArchivedMissingWorktree(prefix: string) { + const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); + createdProjectIds.add(project.projectKey); + const worktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: `${prefix}-${randomUUID().slice(0, 8)}`, + }); + createdProjectIds.add(worktree.projectKey); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + const agent = await createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + workspaceId: worktree.workspaceId, + title: `${prefix}-${randomUUID().slice(0, 8)}`, + }); + + await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + + // Match the remote cloud-race record: workspace archived and absent, while + // the surviving closed agent record is not agent-archived. Refresh now owns + // only agent lifecycle, so its expected cwd failure cannot recover the workspace. + await client.refreshAgent(agent.id).catch(() => undefined); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + expect(existsSync(worktree.workspaceDirectory)).toBe(false); + + return { agent, worktree }; + } + + async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) { + const seeded = await createArchivedMissingWorktree(prefix); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowNotArchived(page, seeded.agent.title); + await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore"); + return seeded; + } + + async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) { + const seeded = await createArchivedMissingWorktree(prefix); + const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId); + const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`); + + await page.goto(`${workspaceRoute}?open=${openAgent}`); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore"); + return seeded; + } + test.afterEach(async () => { for (const directory of createdWorktreeDirectories) { await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); @@ -49,7 +115,7 @@ test.describe("Worktree restore", () => { await tempRepo?.cleanup().catch(() => undefined); }); - test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({ + test("opening an active History agent navigates without restoring or unarchiving", async ({ page, }) => { const serverId = getServerId(); @@ -69,70 +135,176 @@ test.describe("Worktree restore", () => { }); expect(existsSync(worktree.workspaceDirectory)).toBe(true); - await archiveAgentFromDaemon(client, agent.id); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); - await expectSessionRowArchived(page, agent.title); + await expectSessionRowNotArchived(page, agent.title); await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); - await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); expect(existsSync(worktree.workspaceDirectory)).toBe(true); - // The History list is a cached react-query snapshot, so the cleared Archived - // badge only renders after a cold refetch. Reload to remount the query fresh. - await page.reload(); - await waitForSidebarHydration(page); await openSessions(page); - const row = page - .locator('[data-testid^="agent-row-"]') - .filter({ hasText: agent.title }) - .first(); - await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row).not.toContainText("Archived", { timeout: 30_000 }); + 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("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({ + test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({ page, }) => { - const serverId = getServerId(); - const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); - createdProjectIds.add(project.projectKey); - const worktree = await createWorktreeViaDaemon(worktreeClient, { + const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready"); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); + expect(existsSync(worktree.workspaceDirectory)).toBe(false); + await expect( + worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId), + ).resolves.toMatchObject({ kind: "recoverable", action: "restore" }); + }); + + test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => { + const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration( + page, + "restore-success", + ); + await worktreeClient.fetchWorkspaces({ + subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` }, + }); + let updateTimeout: ReturnType | null = null; + let unsubscribeSecondaryWorkspaceUpdate = () => {}; + const secondaryWorkspaceUpdate = new Promise((resolve, reject) => { + updateTimeout = setTimeout( + () => reject(new Error("Secondary client did not receive the restored workspace")), + 30_000, + ); + unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => { + if ( + message.payload.kind === "upsert" && + message.payload.workspace.id === worktree.workspaceId + ) { + resolve(); + } + }); + }); + + try { + await page.getByTestId("workspace-recovery-action").click(); + + await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible(); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await secondaryWorkspaceUpdate; + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: worktree.workspaceId, + }); + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); + } finally { + unsubscribeSecondaryWorkspaceUpdate(); + if (updateTimeout) { + clearTimeout(updateTimeout); + } + } + + const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`; + execFileSync("git", ["branch", switchedBranch], { cwd: tempRepo.path, - slug: `restore-recreate-${randomUUID().slice(0, 8)}`, + stdio: "pipe", }); - createdProjectIds.add(worktree.projectKey); - createdWorktreeDirectories.add(worktree.workspaceDirectory); - - const agent = await createIdleAgent(client, { - cwd: worktree.workspaceDirectory, - workspaceId: worktree.workspaceId, - title: `restore-recreate-${randomUUID().slice(0, 8)}`, + await openChangesPanel(page); + await expectWorkspaceBranch(page, worktree.workspaceName); + await switchBranchFromChangesPanel(page, { + from: worktree.workspaceName, + to: switchedBranch, }); - expect(existsSync(worktree.workspaceDirectory)).toBe(true); + await expectWorkspaceBranch(page, switchedBranch); + await expect( + page.getByTestId("workspace-header-title").filter({ visible: true }).first(), + ).toHaveText(switchedBranch, { timeout: 30_000 }); + }); - // Archive through the default production path the sidebar uses (no explicit - // scope). With the restore prune fix, this default path frees the kept branch - // so the daemon can re-check-out the worktree on restore. - await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); - await expect - .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) - .toBe(false); + test("restore failure stays visible and permits a successful retry", async ({ page }) => { + const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); + const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; + await rename(tempRepo.path, displacedProjectPath); - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openSessions(page); - await expectSessionRowArchived(page, agent.title); - - await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); + try { + await page.getByTestId("workspace-recovery-action").click(); + await expect(page.getByTestId("workspace-recovery-error")).toHaveText( + "The project directory needed to restore this worktree no longer exists.", + ); + await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry"); + expect(existsSync(worktree.workspaceDirectory)).toBe(false); + } finally { + await rename(displacedProjectPath, tempRepo.path); + } + await page.getByTestId("workspace-recovery-action").click(); await expect .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(true); - await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: worktree.workspaceId, + }); + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("an unrecoverable missing workspace shows no misleading recovery action", async ({ + page, + }) => { + const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); + createdProjectIds.add(project.projectKey); + const agent = await createIdleAgent(client, { + cwd: project.workspaceDirectory, + workspaceId: project.workspaceId, + title: `unrecoverable-${randomUUID().slice(0, 8)}`, + }); + await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId); + await client.refreshAgent(agent.id).catch(() => undefined); await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + + const displacedProjectPath = `${tempRepo.path}-missing`; + await rename(tempRepo.path, displacedProjectPath); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowNotArchived(page, agent.title); + await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click(); + + await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByText( + "The archived workspace directory no longer exists and cannot be recreated.", + { exact: true }, + ), + ).toBeVisible(); + await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); + } finally { + await rename(displacedProjectPath, tempRepo.path); + } }); }); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 15ee0a926..d6f26bc8f 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -108,6 +108,12 @@ function HostWorkspaceRouteContent() { ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") : ""; const openValue = getParamValue(globalParams.open); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const workspaceExists = useWorkspaceExists(serverId, workspaceId); + const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent"; + const isOpenIntentWaitingForWorkspace = Boolean( + isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), + ); useEffect(() => { if (!serverId || !workspaceId) { return; @@ -125,6 +131,9 @@ function HostWorkspaceRouteContent() { if (!hasHydratedWorkspaceLayoutStore) { return; } + if (isOpenIntentWaitingForWorkspace) { + return; + } const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { @@ -160,6 +169,7 @@ function HostWorkspaceRouteContent() { setIntentConsumed(true); }, [ hasHydratedWorkspaceLayoutStore, + isOpenIntentWaitingForWorkspace, navigation, openValue, rootNavigationState?.key, @@ -167,14 +177,18 @@ function HostWorkspaceRouteContent() { workspaceId, ]); - if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { + if ( + openValue && + !isOpenIntentWaitingForWorkspace && + (!intentConsumed || !hasHydratedWorkspaceLayoutStore) + ) { return null; } - return ; + return ; } -function WorkspaceDeck() { +function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { const activeSelection = useActiveWorkspaceSelection(); const [mountedSelections, setMountedSelections] = useState(() => activeSelection ? [activeSelection] : [], @@ -219,6 +233,7 @@ function WorkspaceDeck() { key={getWorkspaceSelectionKey(selection)} selection={selection} activeSelection={activeSelection} + recoveryRequested={recoveryRequested} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -230,10 +245,12 @@ function WorkspaceDeck() { function WorkspaceDeckEntry({ selection, activeSelection, + recoveryRequested, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; activeSelection: ActiveWorkspaceSelection; + recoveryRequested: boolean; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); @@ -264,6 +281,7 @@ function WorkspaceDeckEntry({ serverId={selection.serverId} workspaceId={selection.workspaceId} isRouteFocused={isActive} + recoveryRequested={isActive && recoveryRequested} /> ); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 097fd1574..ea999652d 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -21,8 +21,6 @@ import { Archive, ChevronRight } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useQueryClient } from "@tanstack/react-query"; -import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key"; interface AgentListProps { agents: AggregatedAgent[]; @@ -374,7 +372,6 @@ export function AgentList({ const [actionAgent, setActionAgent] = useState(null); const isMobile = useIsCompactFormFactor(); const { archiveAgent } = useArchiveAgent(); - const queryClient = useQueryClient(); const actionClient = useSessionStore((state) => actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, @@ -391,35 +388,15 @@ export function AgentList({ const serverId = agent.serverId; const agentId = agent.id; - const openAgent = () => { - onAgentSelect?.(); - navigateToAgent({ - serverId, - agentId, - workspaceId: agent.workspaceId, - pin: false, - }); - }; - if (agent.archivedAt) { - const client = useSessionStore.getState().sessions[serverId]?.client ?? null; - if (client) { - void client - .refreshAgent(agentId) - .then(() => { - openAgent(); - return queryClient.invalidateQueries({ - queryKey: agentHistoryQueryKey(serverId), - }); - }) - .catch(() => {}); - } - return; - } - - openAgent(); + onAgentSelect?.(); + navigateToAgent({ + serverId, + agentId, + workspaceId: agent.workspaceId, + }); }, - [isActionSheetVisible, onAgentSelect, queryClient], + [isActionSheetVisible, onAgentSelect], ); const handleAgentLongPress = useCallback( diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 7a9af4f6c..a2cdcbe2d 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -489,7 +489,8 @@ describe("translation resources", () => { expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline"); expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}"); expect(en.workspace.route.hostStatus).toBe("Host status: {{status}}"); - expect(en.workspace.route.missing).toBe("Workspace not found"); + expect(en.workspace.route.recovery.archivedTitle).toBe("Workspace archived"); + expect(en.workspace.route.recovery.unavailableTitle).toBe("Workspace unavailable"); expect(en.message.compaction.loading).toBe("Compacting..."); expect(en.message.compaction.auto).toBe("Context automatically compacted"); expect(en.message.compaction.manual).toBe("Context manually compacted"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e266f6f5f..b19073d80 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -335,15 +335,24 @@ export const ar: TranslationResources = { workspace: { route: { loading: "جارٍ تحميل مساحة العمل", - restoring: "جارٍ استعادة مساحة العمل", - restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه", connecting: "الاتصال", hostOffline: "{{hostName}}غير متواجد حالياً", cannotReachHost: "لا يمكن الوصول إلى{{hostName}}", hostStatus: "حالة Host:{{status}}", - missing: "لم يتم العثور على Workspace", needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه", manageHost: "إدارة المضيف", + recovery: { + archivedTitle: "مساحة العمل مؤرشفة", + restoreDescription: + "تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.", + unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.", + restoreAction: "استعادة", + unarchiveAction: "إلغاء الأرشفة", + restoringTitle: "جارٍ استعادة مساحة العمل", + restoringAction: "جارٍ الاستعادة...", + unavailableTitle: "مساحة العمل غير متاحة", + checkFailedTitle: "تعذر التحقق من مساحة العمل", + }, }, hoverCard: { scriptsAccessibility: "البرامج النصية Workspace", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index aa63eb7e8..410cc1fbb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -334,16 +334,24 @@ 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}}", hostStatus: "Host status: {{status}}", - missing: "Workspace not found", needsHostUpgrade: "Update your host to restore this workspace", manageHost: "Manage host", + recovery: { + archivedTitle: "Workspace archived", + restoreDescription: + "{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.", + unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.", + restoreAction: "Restore", + unarchiveAction: "Unarchive", + restoringTitle: "Restoring workspace", + restoringAction: "Restoring...", + unavailableTitle: "Workspace unavailable", + checkFailedTitle: "Couldn't check workspace", + }, }, hoverCard: { scriptsAccessibility: "Workspace scripts", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d0cf8fd4e..183e8e7e2 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -338,16 +338,25 @@ 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}}", hostStatus: "Estado deHost:{{status}}", - missing: "Workspaceno encontrado", needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo", manageHost: "Administrar host", + recovery: { + archivedTitle: "Espacio de trabajo archivado", + restoreDescription: + "{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.", + unarchiveDescription: + "{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.", + restoreAction: "Restaurar", + unarchiveAction: "Desarchivar", + restoringTitle: "Restaurando espacio de trabajo", + restoringAction: "Restaurando...", + unavailableTitle: "Espacio de trabajo no disponible", + checkFailedTitle: "No se pudo comprobar el espacio de trabajo", + }, }, hoverCard: { scriptsAccessibility: "GuionesWorkspace", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7d1e5a81b..8a0031459 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -338,16 +338,24 @@ 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}}", hostStatus: "StatutHost:{{status}}", - missing: "Workspaceintrouvable", needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", manageHost: "Gérer l'hôte", + recovery: { + archivedTitle: "Espace de travail archivé", + restoreDescription: + "{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.", + unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", + restoreAction: "Restaurer", + unarchiveAction: "Désarchiver", + restoringTitle: "Restauration de l'espace de travail", + restoringAction: "Restauration...", + unavailableTitle: "Espace de travail indisponible", + checkFailedTitle: "Impossible de vérifier l'espace de travail", + }, }, hoverCard: { scriptsAccessibility: "ScriptsWorkspace", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a30ca9b45..dd6e65668 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -338,16 +338,25 @@ export const ja: TranslationResources = { workspace: { route: { loading: "ワークスペースを読み込み中", - restoring: "ワークスペースを復元中", - restoreFailed: - "このワークスペースを復元できませんでした。ディレクトリが移動または削除された可能性があります。", connecting: "接続中", hostOffline: "{{hostName}}はオフラインです", cannotReachHost: "{{hostName}}に到達できません", hostStatus: "ホストの状態: {{status}}", - missing: "ワークスペースが見つかりません", needsHostUpgrade: "このワークスペースを復元するにはホストを更新してください", manageHost: "ホストを管理", + recovery: { + archivedTitle: "ワークスペースはアーカイブ済みです", + restoreDescription: + "{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。", + unarchiveDescription: + "{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。", + restoreAction: "復元", + unarchiveAction: "アーカイブを解除", + restoringTitle: "ワークスペースを復元中", + restoringAction: "復元中...", + unavailableTitle: "ワークスペースを利用できません", + checkFailedTitle: "ワークスペースを確認できませんでした", + }, }, hoverCard: { scriptsAccessibility: "ワークスペーススクリプト", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2580729f3..1dc58cb78 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -338,16 +338,25 @@ export const ptBR: TranslationResources = { workspace: { route: { loading: "Carregando workspace", - restoring: "Restaurando workspace", - restoreFailed: - "Não foi possível restaurar este workspace — o diretório pode ter sido movido ou excluído", connecting: "Conectando", hostOffline: "{{hostName}} está offline", cannotReachHost: "Não é possível acessar {{hostName}}", hostStatus: "Status do host: {{status}}", - missing: "Workspace não encontrado", needsHostUpgrade: "Atualize o host para restaurar este workspace", manageHost: "Gerenciar host", + recovery: { + archivedTitle: "Workspace arquivado", + restoreDescription: + "{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.", + unarchiveDescription: + "{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.", + restoreAction: "Restaurar", + unarchiveAction: "Desarquivar", + restoringTitle: "Restaurando workspace", + restoringAction: "Restaurando...", + unavailableTitle: "Workspace indisponível", + checkFailedTitle: "Não foi possível verificar o workspace", + }, }, hoverCard: { scriptsAccessibility: "Scripts do workspace", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 58a8bc067..3d6385dde 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -337,16 +337,25 @@ export const ru: TranslationResources = { workspace: { route: { loading: "Загрузка рабочей области", - restoring: "Восстановление рабочей области", - restoreFailed: - "Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён", connecting: "Подключение", hostOffline: "{{hostName}}не в сети", cannotReachHost: "Невозможно связаться с{{hostName}}", hostStatus: "Статус Host:{{status}}", - missing: "Workspace не найден", needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область", manageHost: "Управление хостом", + recovery: { + archivedTitle: "Рабочая область в архиве", + restoreDescription: + "{{workspaceName}} была архивирована, а её worktree удалён. Восстановите ветку {{branch}}, чтобы открыть её снова.", + unarchiveDescription: + "{{workspaceName}} находится в архиве. Разархивируйте её, чтобы открыть снова.", + restoreAction: "Восстановить", + unarchiveAction: "Разархивировать", + restoringTitle: "Восстановление рабочей области", + restoringAction: "Восстановление...", + unavailableTitle: "Рабочая область недоступна", + checkFailedTitle: "Не удалось проверить рабочую область", + }, }, hoverCard: { scriptsAccessibility: "Скрипты Workspace", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 249f77548..dcce8c541 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -335,15 +335,24 @@ export const zhCN: TranslationResources = { workspace: { route: { loading: "正在加载 workspace", - restoring: "正在恢复 workspace", - restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除", connecting: "正在连接", hostOffline: "{{hostName}} 已离线", cannotReachHost: "无法连接 {{hostName}}", hostStatus: "Host 状态:{{status}}", - missing: "Workspace 未找到", needsHostUpgrade: "更新你的 Host 以恢复此 workspace", manageHost: "管理 Host", + recovery: { + archivedTitle: "Workspace 已归档", + restoreDescription: + "{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。", + unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。", + restoreAction: "恢复", + unarchiveAction: "取消归档", + restoringTitle: "正在恢复 workspace", + restoringAction: "正在恢复...", + unavailableTitle: "Workspace 不可用", + checkFailedTitle: "无法检查 workspace", + }, }, hoverCard: { scriptsAccessibility: "Workspace scripts", diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 868dc13af..68d68da9c 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 a717f157b..6a60e9cd7 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/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index a213e383b..d7fcb32a8 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -1,17 +1,25 @@ import { Text, View } from "react-native"; import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatConnectionStatus } from "@/utils/daemons"; import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); interface WorkspaceRouteStateActions { onRetryHost: () => void; onManageHost: () => void; onDismissMissingWorkspace: () => void; + onRecoverWorkspace: () => void; + onRetryRecoveryInspection: () => void; } export function renderWorkspaceRouteGate(input: { @@ -21,8 +29,21 @@ export function renderWorkspaceRouteGate(input: { switch (input.state.kind) { case "loading": return ; - case "restoring": - return ; + case "missing": + return ( + + ); + case "archived": + return ( + + ); case "needsHostUpgrade": return ( ); - case "missing": + case "recoveryUnavailable": return ( + ); + case "recoveryInspectionFailed": + return ( + ); @@ -69,12 +96,11 @@ function getWorkspaceHostStateTitle( } function WorkspaceConnecting({ hostName }: { hostName: string }) { - const { theme } = useUnistyles(); const { t } = useTranslation(); return ( - + {t("workspace.route.loading")} {hostName} @@ -83,16 +109,90 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) { ); } -function WorkspaceRestoring({ hostName }: { hostName: string }) { - const { theme } = useUnistyles(); +function ArchivedWorkspaceRecovery({ + state, + onRecover, +}: { + state: Extract; + onRecover: () => void; +}) { const { t } = useTranslation(); + const { recovery } = state; + const isRestoring = recovery.phase === "restoring"; + let actionLabel = t("workspace.route.recovery.unarchiveAction"); + if (recovery.recovery.action === "restore") { + actionLabel = t("workspace.route.recovery.restoreAction"); + } + if (recovery.phase === "failed") { + actionLabel = t("common.actions.retry"); + } + const description = + recovery.recovery.action === "restore" + ? t("workspace.route.recovery.restoreDescription", { + workspaceName: recovery.recovery.workspaceName, + branch: recovery.recovery.branch, + }) + : t("workspace.route.recovery.unarchiveDescription", { + workspaceName: recovery.recovery.workspaceName, + }); return ( - + {isRestoring ? ( + + ) : null} - {t("workspace.route.restoring")} - {hostName} + + {isRestoring + ? t("workspace.route.recovery.restoringTitle") + : t("workspace.route.recovery.archivedTitle")} + + {description} + {recovery.error ? ( + + {recovery.error} + + ) : null} + + + + + + ); +} + +function WorkspaceRecoveryInspectionFailed({ + state, + onRetry, + onDismiss, +}: { + state: Extract; + onRetry: () => void; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + return ( + + + {t("workspace.route.recovery.checkFailedTitle")} + {state.error} + + + + ); @@ -107,14 +207,13 @@ function WorkspaceUnreachable({ onRetry: () => void; onManageHost: () => void; }) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error"; return ( {state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? ( - + ) : null} {getWorkspaceHostStateTitle(state, t)} @@ -155,13 +254,12 @@ function WorkspaceUnreachable({ function WorkspaceEmptyState({ titleKey, hostName, + description, onDismiss, }: { - titleKey: - | "workspace.route.missing" - | "workspace.route.restoreFailed" - | "workspace.route.needsHostUpgrade"; - hostName: string; + titleKey: "workspace.route.needsHostUpgrade" | "workspace.route.recovery.unavailableTitle"; + hostName?: string; + description?: string; onDismiss: () => void; }) { const { t } = useTranslation(); @@ -170,7 +268,7 @@ function WorkspaceEmptyState({ {t(titleKey)} - {hostName} + {description ?? hostName}