From 5da6548affc750e888db88ec3ded127dce649c0b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 17:22:37 +0200 Subject: [PATCH] Improve the archived workspace restore flow (#2002) * fix(app): restore missing workspaces from History Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation. * fix(app): wait for workspace hydration before restore * fix(app): preserve History agent unarchive * fix(app): reopen archived History agents outside active directory History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation. * fix(app): ignore stale History archive state for active agents A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh. * fix(app): reopen every selected archived History agent History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent. * fix(app): require explicit workspace recovery * fix: preserve workspace recovery contracts * fix: preserve workspace recovery compatibility * fix: preserve recovered workspace session state * fix: preserve terminal navigation timing * fix(server): preserve successful workspace recovery * fix: preserve workspace recovery intent --- docs/agent-lifecycle.md | 6 + packages/app/e2e/archive-tab.spec.ts | 10 +- packages/app/e2e/helpers/archive-tab.ts | 6 - packages/app/e2e/helpers/new-workspace.ts | 2 + .../app/e2e/helpers/schedule-fake-host.ts | 2 +- packages/app/e2e/helpers/seed-client.ts | 6 +- packages/app/e2e/helpers/terminal-perf.ts | 7 +- packages/app/e2e/worktree-restore.spec.ts | 260 +++++++++++++++--- .../workspace/[workspaceId]/index.tsx | 24 +- packages/app/src/components/agent-list.tsx | 37 +-- packages/app/src/i18n/resources.test.ts | 3 +- packages/app/src/i18n/resources/ar.ts | 15 +- packages/app/src/i18n/resources/en.ts | 16 +- packages/app/src/i18n/resources/es.ts | 17 +- packages/app/src/i18n/resources/fr.ts | 16 +- packages/app/src/i18n/resources/ja.ts | 17 +- packages/app/src/i18n/resources/pt-BR.ts | 17 +- packages/app/src/i18n/resources/ru.ts | 17 +- packages/app/src/i18n/resources/zh-CN.ts | 15 +- .../workspace-route-navigation.test.ts | 28 ++ .../navigation/workspace-route-navigation.ts | 3 + .../workspace/workspace-route-state-views.tsx | 144 ++++++++-- .../workspace/workspace-route-state.test.ts | 235 ++++++++-------- .../workspace/workspace-route-state.ts | 80 ++++-- .../screens/workspace/workspace-screen.tsx | 42 ++- .../index.ts | 2 +- .../navigation.test.ts | 18 ++ .../navigation.ts | 24 +- packages/app/src/stores/session-store.test.ts | 45 --- packages/app/src/stores/session-store.ts | 81 ------ packages/app/src/utils/host-routes.ts | 7 +- .../app/src/utils/navigate-to-agent/index.ts | 59 +--- .../utils/navigate-to-agent/resolve.test.ts | 36 --- .../src/utils/navigate-to-agent/resolve.ts | 13 - .../restore-archived-workspace.test.ts | 206 -------------- .../app/src/workspace-recovery/model.test.ts | 31 +++ packages/app/src/workspace-recovery/model.ts | 114 ++++++++ .../use-workspace-recovery.ts | 105 +++++++ packages/client/src/daemon-client.ts | 31 +++ packages/protocol/src/messages.ts | 61 ++++ .../src/messages.workspace-recovery.test.ts | 135 +++++++++ .../src/server/daemon-client.e2e.test.ts | 2 + packages/server/src/server/session.ts | 179 ++++++++++-- .../src/server/session.workspaces.test.ts | 141 +++++++--- .../workspace-recovery-service.test.ts | 131 +++++++++ .../workspace-recovery-service.ts | 135 +++++++++ .../server/src/server/websocket-server.ts | 11 +- 47 files changed, 1769 insertions(+), 823 deletions(-) delete mode 100644 packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts create mode 100644 packages/app/src/workspace-recovery/model.test.ts create mode 100644 packages/app/src/workspace-recovery/model.ts create mode 100644 packages/app/src/workspace-recovery/use-workspace-recovery.ts create mode 100644 packages/protocol/src/messages.workspace-recovery.test.ts create mode 100644 packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts create mode 100644 packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts 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}