diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2488ec8404..63d8123250 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -54,8 +54,9 @@ 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 restore must therefore follow explicit restore intent for that missing -workspace identity; it must not infer workspace lifecycle from `agent.archivedAt`. +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 diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 42583e46f5..3daaaef533 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 05ae4d42f1..979768c683 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 b907017f1e..d14e0b0405 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -17,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick< | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" + | "inspectWorkspaceRecovery" | "patchDaemonConfig" | "removeProject" >; diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/helpers/schedule-fake-host.ts index 3f49468019..49746b495d 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 3cb5abdfe8..6a04bbd510 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/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index b0f184d309..e297dfb769 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -1,16 +1,18 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; +import { rename } from "node:fs/promises"; +import type { Page } from "@playwright/test"; import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { - archiveAgentFromDaemon, createIdleAgent, - expectSessionRowArchived, + expectSessionRowNotArchived, fetchAgentArchivedAt, openSessions, } from "./helpers/archive-tab"; import { archiveWorkspaceFromDaemon, + archiveLocalWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, @@ -35,6 +37,50 @@ 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; + } + test.afterEach(async () => { for (const directory of createdWorktreeDirectories) { await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); @@ -49,7 +95,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 +115,115 @@ 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 }); }); - 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, { - cwd: tempRepo.path, - slug: `restore-recreate-${randomUUID().slice(0, 8)}`, - }); - createdProjectIds.add(worktree.projectKey); - createdWorktreeDirectories.add(worktree.workspaceDirectory); + 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" }); + }); - const agent = await createIdleAgent(client, { - cwd: worktree.workspaceDirectory, - workspaceId: worktree.workspaceId, - title: `restore-recreate-${randomUUID().slice(0, 8)}`, - }); - expect(existsSync(worktree.workspaceDirectory)).toBe(true); + test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => { + const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-success"); - // 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); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openSessions(page); - await expectSessionRowArchived(page, agent.title); - - await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); + 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 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 }); + await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); + expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); + }); + + test("restore failure stays visible and permits a successful retry", async ({ page }) => { + const { worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); + const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; + await rename(tempRepo.path, displacedProjectPath); + + 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: getServerId(), + workspaceId: worktree.workspaceId, + }); + }); + + 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/_layout.tsx b/packages/app/src/app/_layout.tsx index 0889c4c4aa..491c53d97f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -143,7 +143,7 @@ function PushNotificationRouter() { const serverId = target.serverId; const agentId = target.agentId; if (serverId && agentId) { - navigateToAgent({ serverId, agentId, pin: true }); + navigateToAgent({ serverId, agentId }); return; } 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 15ee0a9267..0621d2f63a 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,11 @@ function HostWorkspaceRouteContent() { ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") : ""; const openValue = getParamValue(globalParams.open); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const workspaceExists = useWorkspaceExists(serverId, workspaceId); + const isOpenIntentWaitingForWorkspace = Boolean( + openValue && hasHydratedWorkspaces && !workspaceExists, + ); useEffect(() => { if (!serverId || !workspaceId) { return; @@ -125,6 +130,9 @@ function HostWorkspaceRouteContent() { if (!hasHydratedWorkspaceLayoutStore) { return; } + if (isOpenIntentWaitingForWorkspace) { + return; + } const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { @@ -160,6 +168,7 @@ function HostWorkspaceRouteContent() { setIntentConsumed(true); }, [ hasHydratedWorkspaceLayoutStore, + isOpenIntentWaitingForWorkspace, navigation, openValue, rootNavigationState?.key, @@ -167,7 +176,11 @@ function HostWorkspaceRouteContent() { workspaceId, ]); - if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { + if ( + openValue && + !isOpenIntentWaitingForWorkspace && + (!intentConsumed || !hasHydratedWorkspaceLayoutStore) + ) { return null; } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 62ac3c4448..ea999652d0 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -394,8 +394,6 @@ export function AgentList({ serverId, agentId, workspaceId: agent.workspaceId, - restoreWorkspace: { agentArchived: Boolean(agent.archivedAt) }, - pin: false, }); }, [isActionSheetVisible, onAgentSelect], diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 7a9af4f6c7..a2cdcbe2d5 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 e909e6eebb..e7cbaa4ee7 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 e379d4367e..b3d3211066 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 902cbd0b6e..1d5e73086b 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 6919a443a5..c637f21d0d 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 54cfefebe4..634120d919 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 22995e17dc..09bf982df9 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 f7496e7498..562d1f2c35 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 bfc8fbb294..518b2e8e1b 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/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index a213e383bd..c5cfd38d99 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,13 @@ export function renderWorkspaceRouteGate(input: { switch (input.state.kind) { case "loading": return ; - case "restoring": - return ; + case "archived": + return ( + + ); case "needsHostUpgrade": return ( ); - case "missing": + case "recoveryUnavailable": return ( + ); + case "recoveryInspectionFailed": + return ( + ); @@ -69,12 +88,11 @@ function getWorkspaceHostStateTitle( } function WorkspaceConnecting({ hostName }: { hostName: string }) { - const { theme } = useUnistyles(); const { t } = useTranslation(); return ( - + {t("workspace.route.loading")} {hostName} @@ -83,16 +101,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 +199,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 +246,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 +260,7 @@ function WorkspaceEmptyState({ {t(titleKey)} - {hostName} + {description ?? hostName}