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
This commit is contained in:
@@ -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 <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||
case "restoring":
|
||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||
case "missing":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey="workspace.route.recovery.unavailableTitle"
|
||||
hostName={input.state.hostName}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "archived":
|
||||
return (
|
||||
<ArchivedWorkspaceRecovery
|
||||
state={input.state}
|
||||
onRecover={input.actions.onRecoverWorkspace}
|
||||
/>
|
||||
);
|
||||
case "needsHostUpgrade":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
@@ -39,13 +60,19 @@ export function renderWorkspaceRouteGate(input: {
|
||||
onManageHost={input.actions.onManageHost}
|
||||
/>
|
||||
);
|
||||
case "missing":
|
||||
case "recoveryUnavailable":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey={
|
||||
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing"
|
||||
}
|
||||
hostName={input.state.hostName}
|
||||
titleKey="workspace.route.recovery.unavailableTitle"
|
||||
description={input.state.message}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "recoveryInspectionFailed":
|
||||
return (
|
||||
<WorkspaceRecoveryInspectionFailed
|
||||
state={input.state}
|
||||
onRetry={input.actions.onRetryRecoveryInspection}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
@@ -69,12 +96,11 @@ function getWorkspaceHostStateTitle(
|
||||
}
|
||||
|
||||
function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.loading")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
@@ -83,16 +109,90 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRestoring({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
function ArchivedWorkspaceRecovery({
|
||||
state,
|
||||
onRecover,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "archived" }>;
|
||||
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 (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
{isRestoring ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.title}>
|
||||
{isRestoring
|
||||
? t("workspace.route.recovery.restoringTitle")
|
||||
: t("workspace.route.recovery.archivedTitle")}
|
||||
</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
{recovery.error ? (
|
||||
<Text style={styles.error} testID="workspace-recovery-error">
|
||||
{recovery.error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
leftIcon={isRestoring ? undefined : RotateCw}
|
||||
onPress={onRecover}
|
||||
disabled={isRestoring}
|
||||
testID="workspace-recovery-action"
|
||||
>
|
||||
{isRestoring ? t("workspace.route.recovery.restoringAction") : actionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRecoveryInspectionFailed({
|
||||
state,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "recoveryInspectionFailed" }>;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.recovery.checkFailedTitle")}</Text>
|
||||
<Text style={styles.error}>{state.error}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||
{t("common.actions.back")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -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 (
|
||||
<View style={styles.emptyState}>
|
||||
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text>
|
||||
@@ -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({
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t(titleKey)}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.description}>{description ?? hostName}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import { resolveWorkspaceRouteState } from "./workspace-route-state";
|
||||
|
||||
function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
||||
@@ -20,18 +21,38 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(input: {
|
||||
workspace?: WorkspaceDescriptor | null;
|
||||
connectionStatus?: "online" | "offline";
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
recovery?: WorkspaceRecoveryModel;
|
||||
}) {
|
||||
return resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: input.connectionStatus ?? "online",
|
||||
lastError: input.connectionStatus === "offline" ? "transport closed" : null,
|
||||
workspace: input.workspace ?? null,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces ?? true,
|
||||
recovery: input.recovery ?? { kind: "checking" },
|
||||
});
|
||||
}
|
||||
|
||||
const recoverable = {
|
||||
kind: "recoverable",
|
||||
recovery: {
|
||||
kind: "recoverable",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Feature branch",
|
||||
action: "restore",
|
||||
branch: "feature",
|
||||
},
|
||||
phase: "ready",
|
||||
error: null,
|
||||
} as const satisfies WorkspaceRecoveryModel;
|
||||
|
||||
describe("resolveWorkspaceRouteState", () => {
|
||||
it("returns unreachable when no descriptor is cached and the host is offline", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
it("keeps a missing route unreachable while its host is offline", () => {
|
||||
expect(resolve({ connectionStatus: "offline" })).toEqual({
|
||||
kind: "unreachable",
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
@@ -39,34 +60,9 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps offline routes unreachable after workspace hydration", () => {
|
||||
it("keeps a cached workspace visible while its host reconnects", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unreachable",
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns reconnecting when the descriptor is cached and the host is offline", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
resolve({ workspace: createWorkspaceDescriptor(), connectionStatus: "offline" }),
|
||||
).toEqual({
|
||||
kind: "reconnecting",
|
||||
hostName: "Laptop",
|
||||
@@ -75,93 +71,102 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing after workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
|
||||
it("waits for workspace hydration and authoritative recovery inspection", () => {
|
||||
expect(resolve({ hasHydratedWorkspaces: false })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(resolve({ recovery: { kind: "checking" } })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns loading before workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "loading", hostName: "Laptop" });
|
||||
it("keeps an ordinary missing route distinct from an explicit recovery request", () => {
|
||||
expect(resolve({ recovery: { kind: "idle" } })).toEqual({
|
||||
kind: "missing",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
|
||||
kind: "needsHostUpgrade",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ready when the host is online and the descriptor exists", () => {
|
||||
it("renders the authoritative archived-workspace state through ready, restoring, and failed phases", () => {
|
||||
expect(resolve({ recovery: recoverable })).toEqual({
|
||||
kind: "archived",
|
||||
hostName: "Laptop",
|
||||
recovery: recoverable,
|
||||
});
|
||||
|
||||
const restoring = { ...recoverable, phase: "restoring" as const };
|
||||
expect(resolve({ recovery: restoring })).toMatchObject({
|
||||
kind: "archived",
|
||||
recovery: { phase: "restoring" },
|
||||
});
|
||||
|
||||
const failed = {
|
||||
...recoverable,
|
||||
phase: "failed" as const,
|
||||
error: "Project root is missing",
|
||||
};
|
||||
expect(resolve({ recovery: failed })).toMatchObject({
|
||||
kind: "archived",
|
||||
recovery: { phase: "failed", error: "Project root is missing" },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes unsupported and authoritatively unavailable workspaces", () => {
|
||||
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
|
||||
kind: "needsHostUpgrade",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
resolve({
|
||||
recovery: {
|
||||
kind: "unavailable",
|
||||
recovery: {
|
||||
kind: "unavailable",
|
||||
workspaceId: "workspace-1",
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The workspace directory cannot be recreated.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: "Laptop",
|
||||
message: "The workspace directory cannot be recreated.",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolve({
|
||||
recovery: {
|
||||
kind: "unsupportedAction",
|
||||
action: "repair_from_snapshot",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: "Laptop",
|
||||
message: "Update Paseo to recover this workspace.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps loading visible when the descriptor arrives, then uses it as the success transition", () => {
|
||||
const restoring = { ...recoverable, phase: "restoring" as const };
|
||||
expect(
|
||||
resolve({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
recovery: restoring,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
).toEqual({ kind: "archived", hostName: "Laptop", recovery: restoring });
|
||||
|
||||
it("returns restoring while an archived workspace restore is in flight", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
}),
|
||||
).toEqual({ kind: "restoring", hostName: "Laptop" });
|
||||
});
|
||||
|
||||
it("returns needsHostUpgrade when the daemon lacks the restore capability", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "needs-host-upgrade",
|
||||
}),
|
||||
).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" });
|
||||
});
|
||||
|
||||
it("falls back to a restore-failed missing state once the restore times out", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "failed",
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
|
||||
});
|
||||
|
||||
it("resolves to ready when the descriptor arrives even while restoring", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
resolve({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
recovery: recoverable,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
|
||||
export type WorkspaceRouteState =
|
||||
| { kind: "ready" }
|
||||
@@ -16,9 +17,15 @@ export type WorkspaceRouteState =
|
||||
lastError: string | null;
|
||||
}
|
||||
| { kind: "loading"; hostName: string }
|
||||
| { kind: "restoring"; hostName: string }
|
||||
| { kind: "missing"; hostName: string }
|
||||
| { kind: "needsHostUpgrade"; hostName: string }
|
||||
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||
| {
|
||||
kind: "archived";
|
||||
hostName: string;
|
||||
recovery: Extract<WorkspaceRecoveryModel, { kind: "recoverable" }>;
|
||||
}
|
||||
| { kind: "recoveryUnavailable"; hostName: string; message: string }
|
||||
| { kind: "recoveryInspectionFailed"; hostName: string; error: string };
|
||||
|
||||
export function resolveWorkspaceRouteState(input: {
|
||||
hostName: string;
|
||||
@@ -26,10 +33,13 @@ export function resolveWorkspaceRouteState(input: {
|
||||
lastError: string | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
if (input.workspace) {
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
|
||||
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
|
||||
}
|
||||
return { kind: "ready" };
|
||||
}
|
||||
|
||||
@@ -41,30 +51,46 @@ export function resolveWorkspaceRouteState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.restoreStatus === "restoring") {
|
||||
return { kind: "restoring", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.restoreStatus === "needs-host-upgrade") {
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.hasHydratedWorkspaces) {
|
||||
return {
|
||||
kind: "missing",
|
||||
hostName: input.hostName,
|
||||
restoreFailed: input.restoreStatus === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
if (input.connectionStatus !== "online") {
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
if (!input.hasHydratedWorkspaces) {
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
}
|
||||
if (input.recovery.kind === "idle") {
|
||||
return { kind: "missing", hostName: input.hostName };
|
||||
}
|
||||
|
||||
switch (input.recovery.kind) {
|
||||
case "checking":
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
case "needsHostUpgrade":
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
case "recoverable":
|
||||
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
|
||||
case "unavailable":
|
||||
return {
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: input.hostName,
|
||||
message: input.recovery.recovery.message,
|
||||
};
|
||||
case "unsupportedAction":
|
||||
return {
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: input.hostName,
|
||||
message: "Update Paseo to recover this workspace.",
|
||||
};
|
||||
case "inspectionFailed":
|
||||
return {
|
||||
kind: "recoveryInspectionFailed",
|
||||
hostName: input.hostName,
|
||||
error: input.recovery.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,7 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
useSessionStore,
|
||||
useWorkspaceRestoreStatus,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
@@ -144,6 +140,8 @@ import {
|
||||
type WorkspaceRouteState,
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
|
||||
import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recovery";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
@@ -283,10 +281,12 @@ interface WorkspaceScreenProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -896,6 +896,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
return (
|
||||
@@ -903,6 +904,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1462,9 +1464,9 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
||||
|
||||
function useResolvedWorkspaceRouteState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
const hosts = useHosts();
|
||||
const host = useMemo(
|
||||
@@ -1473,8 +1475,6 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
);
|
||||
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
||||
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
||||
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveWorkspaceRouteState({
|
||||
@@ -1483,7 +1483,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
lastError: hostSnapshot?.lastError ?? null,
|
||||
workspace: input.workspace,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
recovery: input.recovery,
|
||||
}),
|
||||
[
|
||||
hostName,
|
||||
@@ -1491,11 +1491,19 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
hostSnapshot?.lastError,
|
||||
input.workspace,
|
||||
input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
input.recovery,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
recoveryRequested: boolean,
|
||||
): boolean {
|
||||
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
@@ -1691,6 +1699,7 @@ function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1752,6 +1761,15 @@ function WorkspaceScreenContent({
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
recoveryRequested,
|
||||
),
|
||||
});
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
@@ -1827,9 +1845,9 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
recovery: workspaceRecovery.state,
|
||||
});
|
||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||
isCheckoutStatusLoading,
|
||||
@@ -3291,6 +3309,8 @@ function WorkspaceScreenContent({
|
||||
onRetryHost: handleRetryHost,
|
||||
onManageHost: handleManageHost,
|
||||
onDismissMissingWorkspace: handleDismissMissingWorkspace,
|
||||
onRecoverWorkspace: workspaceRecovery.restore,
|
||||
onRetryRecoveryInspection: workspaceRecovery.retryInspection,
|
||||
},
|
||||
});
|
||||
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({
|
||||
|
||||
Reference in New Issue
Block a user