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:
@@ -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 <WorkspaceDeck />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck() {
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
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}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -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<AggregatedAgent | null>(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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ワークスペーススクリプト",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
navigateToLastWorkspace as navigateToLastWorkspacePure,
|
||||
navigateToWorkspace as navigateToWorkspacePure,
|
||||
parseActiveWorkspaceSelection,
|
||||
type NavigateToWorkspaceInput,
|
||||
type NavigateToWorkspaceDeps,
|
||||
type NavigateToWorkspaceInput,
|
||||
} from "./navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
@@ -134,6 +134,24 @@ describe("workspace navigation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
});
|
||||
|
||||
navigateToWorkspace(
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
const selection = parseActiveWorkspaceSelection({
|
||||
pathname: "/h/server-1/workspace/workspace-a",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
import {
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -82,14 +83,16 @@ export function navigateToWorkspace(
|
||||
input: NavigateToWorkspaceInput,
|
||||
deps: NavigateToWorkspaceDeps,
|
||||
): string {
|
||||
const workspaces = deps.getSessionWorkspaces(input.serverId);
|
||||
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.target) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
if (resolvedWorkspaceId || input.target.kind !== "agent") {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
} else {
|
||||
const workspaces = deps.getSessionWorkspaces(input.serverId);
|
||||
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(input.serverId)).filter(
|
||||
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
|
||||
@@ -104,7 +107,14 @@ export function navigateToWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
const route =
|
||||
input.target?.kind === "agent" && !resolvedWorkspaceId
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
`agent:${input.target.agentId}`,
|
||||
)
|
||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
||||
deps.navigateToRoute(route);
|
||||
return route;
|
||||
|
||||
@@ -364,51 +364,6 @@ describe("mergeWorkspaces", () => {
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a pending restore status when the matching descriptor lands", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
|
||||
"restoring",
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaceRestoreStatus", () => {
|
||||
it("marks restoring then failed while the workspace is still absent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores a late failed once the descriptor has landed (no-op)", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores failed when no restore is in flight", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaces", () => {
|
||||
|
||||
@@ -317,8 +317,6 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -365,9 +363,6 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -488,13 +483,6 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -567,7 +555,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1331,54 +1318,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1392,18 +1331,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1423,7 +1354,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1642,14 +1572,3 @@ export const useSessionStore = create<SessionStore>()(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export function useWorkspaceRestoreStatus(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceRestoreStatus | null {
|
||||
return useSessionStore((state) =>
|
||||
serverId && workspaceId
|
||||
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent(
|
||||
export function parseHostWorkspaceOpenIntentFromPathname(
|
||||
pathname: string,
|
||||
): WorkspaceOpenIntent | null {
|
||||
return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname));
|
||||
}
|
||||
|
||||
export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null {
|
||||
const search = extractSearch(pathname);
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
|
||||
const open = new URLSearchParams(search).get("open");
|
||||
return parseWorkspaceOpenIntent(open) ? open : null;
|
||||
}
|
||||
|
||||
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
||||
|
||||
@@ -1,62 +1,10 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
|
||||
export type { NavigateToAgentInput } from "./resolve";
|
||||
|
||||
// Clears the transient restoring state if the daemon resolves refreshAgent without
|
||||
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
|
||||
// forever. Recreating a worktree can require a git fetch, so the budget is generous
|
||||
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||
const RESTORE_TIMEOUT_MS = 30000;
|
||||
|
||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
// Self-gate: only an archived agent whose workspace is absent needs restoring.
|
||||
// A still-present workspace or an in-flight restore is a no-op; fire-once is
|
||||
// derived from store state.
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
if (!agent?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
// Single capability read for restore. An old daemon recreates nothing on
|
||||
// refresh_agent, so a gone directory would spin then flash a misleading
|
||||
// "couldn't restore". Surface an explicit "update your host" state instead.
|
||||
if (session?.serverInfo?.features?.worktreeRestore !== true) {
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
|
||||
return;
|
||||
}
|
||||
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
|
||||
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
|
||||
setTimeout(
|
||||
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
RESTORE_TIMEOUT_MS,
|
||||
);
|
||||
client
|
||||
.refreshAgent(agentId)
|
||||
.catch(() =>
|
||||
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
);
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
return resolveNavigateToAgent(input, {
|
||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||
@@ -70,8 +18,5 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
|
||||
restoreArchivedWorkspace(serverId, agentId, workspaceId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,25 +16,16 @@ interface RecordedHostNav {
|
||||
|
||||
interface RecordedTabNav extends NavigateToWorkspaceInput {}
|
||||
|
||||
interface RecordedRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function createFakeNavigators(target: AgentNavTarget): {
|
||||
deps: NavigateToAgentDeps;
|
||||
hostNavigations: RecordedHostNav[];
|
||||
tabNavigations: RecordedTabNav[];
|
||||
restores: RecordedRestore[];
|
||||
} {
|
||||
const hostNavigations: RecordedHostNav[] = [];
|
||||
const tabNavigations: RecordedTabNav[] = [];
|
||||
const restores: RecordedRestore[] = [];
|
||||
return {
|
||||
hostNavigations,
|
||||
tabNavigations,
|
||||
restores,
|
||||
deps: {
|
||||
readAgentNavTarget: () => target,
|
||||
navigateToHostAgent: (route) => {
|
||||
@@ -44,9 +35,6 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
tabNavigations.push(input);
|
||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
||||
},
|
||||
restoreArchivedWorkspace: (input) => {
|
||||
restores.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,19 +62,6 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates a restore attempt whenever a workspaceId resolves", () => {
|
||||
const { deps, restores, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
|
||||
]);
|
||||
expect(tabNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
@@ -111,17 +86,6 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trigger a restore when no workspaceId resolves", () => {
|
||||
const { deps, restores, hostNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
|
||||
|
||||
expect(restores).toEqual([]);
|
||||
expect(hostNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -19,11 +19,6 @@ export interface NavigateToAgentDeps {
|
||||
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
||||
navigateToHostAgent: (route: string) => void;
|
||||
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
|
||||
restoreArchivedWorkspace: (input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function resolveNavigateToAgent(
|
||||
@@ -41,14 +36,6 @@ export function resolveNavigateToAgent(
|
||||
return route;
|
||||
}
|
||||
|
||||
// Restore self-gates on the agent being archived with its workspace absent, so
|
||||
// ordinary navigations are a cheap no-op.
|
||||
deps.restoreArchivedWorkspace({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return deps.navigateToWorkspace({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
|
||||
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
|
||||
let connected = true;
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
|
||||
}),
|
||||
isHostRuntimeConnected: () => connected,
|
||||
}));
|
||||
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function agent(archivedAt: Date | null): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: createdAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
archivedAt,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
store.updateSessionServerInfo(SERVER_ID, {
|
||||
serverId: SERVER_ID,
|
||||
hostname: "host",
|
||||
version: "0.1.98",
|
||||
features: { worktreeRestore: options?.worktreeRestore ?? true },
|
||||
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
refreshAgent.mockReset();
|
||||
connected = true;
|
||||
seedArchivedAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
function trigger(): void {
|
||||
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
}
|
||||
|
||||
it("calls refreshAgent once and marks the workspace restoring", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("does not re-fire while a restore for the same workspace is in flight", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire for a non-archived agent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not fire while disconnected", () => {
|
||||
connected = false;
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("flips to failed when refreshAgent rejects", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
|
||||
|
||||
trigger();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.runAllTicks();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.resolve({}));
|
||||
|
||||
trigger();
|
||||
await Promise.resolve();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
seedArchivedAgent({ worktreeRestore: false });
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBe("needs-host-upgrade");
|
||||
});
|
||||
|
||||
it("is a no-op when the workspace descriptor is already present", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
]);
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWorkspaceRecoveryModel } from "./model";
|
||||
|
||||
describe("resolveWorkspaceRecoveryModel", () => {
|
||||
it("keeps newer recovery actions visible but non-actionable", () => {
|
||||
expect(
|
||||
resolveWorkspaceRecoveryModel({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
hasClient: true,
|
||||
hasServerInfo: true,
|
||||
supportsRecovery: true,
|
||||
inspection: {
|
||||
pending: false,
|
||||
error: null,
|
||||
data: {
|
||||
kind: "recoverable",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Feature branch",
|
||||
action: "repair_from_snapshot",
|
||||
branch: "feature",
|
||||
},
|
||||
},
|
||||
restore: { pending: false, error: null },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unsupportedAction",
|
||||
action: "repair_from_snapshot",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { WorkspaceRecoveryState as AuthoritativeWorkspaceRecoveryState } from "@getpaseo/protocol/messages";
|
||||
|
||||
type AuthoritativeRecoverableWorkspace = Extract<
|
||||
AuthoritativeWorkspaceRecoveryState,
|
||||
{ kind: "recoverable" }
|
||||
>;
|
||||
|
||||
export type SupportedWorkspaceRecoveryAction = "unarchive" | "restore";
|
||||
|
||||
type SupportedRecoverableWorkspace = Omit<AuthoritativeRecoverableWorkspace, "action"> & {
|
||||
action: SupportedWorkspaceRecoveryAction;
|
||||
};
|
||||
|
||||
export type WorkspaceRecoveryModel =
|
||||
| { kind: "idle" }
|
||||
| { kind: "checking" }
|
||||
| { kind: "needsHostUpgrade" }
|
||||
| {
|
||||
kind: "recoverable";
|
||||
recovery: SupportedRecoverableWorkspace;
|
||||
phase: "ready" | "restoring" | "failed";
|
||||
error: string | null;
|
||||
}
|
||||
| { kind: "unsupportedAction"; action: string }
|
||||
| {
|
||||
kind: "unavailable";
|
||||
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
}
|
||||
| { kind: "inspectionFailed"; error: string };
|
||||
|
||||
export interface WorkspaceRecoveryController {
|
||||
state: WorkspaceRecoveryModel;
|
||||
restore: () => void;
|
||||
retryInspection: () => void;
|
||||
}
|
||||
|
||||
function resolveRecoveryPhase(input: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
}): "ready" | "restoring" | "failed" {
|
||||
if (input.pending) {
|
||||
return "restoring";
|
||||
}
|
||||
if (input.error) {
|
||||
return "failed";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function getSupportedRecovery(
|
||||
recovery: AuthoritativeWorkspaceRecoveryState | undefined,
|
||||
): SupportedRecoverableWorkspace | null {
|
||||
if (recovery?.kind !== "recoverable") {
|
||||
return null;
|
||||
}
|
||||
if (recovery.action !== "restore" && recovery.action !== "unarchive") {
|
||||
return null;
|
||||
}
|
||||
return { ...recovery, action: recovery.action };
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRecoveryModel(input: {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
hasClient: boolean;
|
||||
hasServerInfo: boolean;
|
||||
supportsRecovery: boolean;
|
||||
inspection: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
data: AuthoritativeWorkspaceRecoveryState | undefined;
|
||||
};
|
||||
restore: { pending: boolean; error: string | null };
|
||||
}): WorkspaceRecoveryModel {
|
||||
const supportedRecovery = getSupportedRecovery(input.inspection.data);
|
||||
if (input.restore.pending && supportedRecovery) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: supportedRecovery,
|
||||
phase: "restoring",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (!input.enabled || !input.connected || !input.hasClient) {
|
||||
return { kind: "idle" };
|
||||
}
|
||||
if (!input.hasServerInfo) {
|
||||
return { kind: "checking" };
|
||||
}
|
||||
if (!input.supportsRecovery) {
|
||||
return { kind: "needsHostUpgrade" };
|
||||
}
|
||||
if (input.inspection.pending) {
|
||||
return { kind: "checking" };
|
||||
}
|
||||
if (input.inspection.error) {
|
||||
return { kind: "inspectionFailed", error: input.inspection.error };
|
||||
}
|
||||
if (input.inspection.data?.kind === "unavailable") {
|
||||
return { kind: "unavailable", recovery: input.inspection.data };
|
||||
}
|
||||
if (input.inspection.data?.kind === "recoverable" && !supportedRecovery) {
|
||||
return { kind: "unsupportedAction", action: input.inspection.data.action };
|
||||
}
|
||||
if (supportedRecovery) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: supportedRecovery,
|
||||
phase: resolveRecoveryPhase(input.restore),
|
||||
error: input.restore.error,
|
||||
};
|
||||
}
|
||||
return { kind: "checking" };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
|
||||
|
||||
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
|
||||
|
||||
const MIN_RECOVERY_LOADING_MS = 350;
|
||||
|
||||
function waitForAnimationFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
async function waitForRecoveryLoadingPresentation(): Promise<void> {
|
||||
// The first callback runs before paint. Waiting for the following frame gives
|
||||
// React a paint opportunity before a fast daemon response adds the workspace.
|
||||
await waitForAnimationFrame();
|
||||
await waitForAnimationFrame();
|
||||
}
|
||||
|
||||
function waitForMinimumRecoveryLoadingTime(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, MIN_RECOVERY_LOADING_MS));
|
||||
}
|
||||
|
||||
export function useWorkspaceRecovery(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
enabled: boolean;
|
||||
}): WorkspaceRecoveryController {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const serverInfo = useSessionStore((store) => store.sessions[input.serverId]?.serverInfo ?? null);
|
||||
const supportsRecovery = serverInfo?.features?.workspaceRecovery === true;
|
||||
|
||||
const inspection = useFetchQuery({
|
||||
queryKey: ["workspaceRecovery", input.serverId, input.workspaceId],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5_000,
|
||||
enabled: Boolean(input.enabled && client && isConnected && supportsRecovery),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("The host client is unavailable.");
|
||||
}
|
||||
return client.inspectWorkspaceRecovery(input.workspaceId);
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("The host disconnected before the workspace could be recovered.");
|
||||
}
|
||||
await waitForRecoveryLoadingPresentation();
|
||||
await waitForMinimumRecoveryLoadingTime();
|
||||
await client.restoreWorkspace(input.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
const state = useMemo(
|
||||
() =>
|
||||
resolveWorkspaceRecoveryModel({
|
||||
enabled: input.enabled,
|
||||
connected: isConnected,
|
||||
hasClient: client !== null,
|
||||
hasServerInfo: serverInfo !== null,
|
||||
supportsRecovery,
|
||||
inspection: {
|
||||
pending: inspection.isPending,
|
||||
error: inspection.isError ? toErrorMessage(inspection.error) : null,
|
||||
data: inspection.data,
|
||||
},
|
||||
restore: {
|
||||
pending: restoreMutation.isPending,
|
||||
error: restoreMutation.isError ? toErrorMessage(restoreMutation.error) : null,
|
||||
},
|
||||
}),
|
||||
[
|
||||
client,
|
||||
input.enabled,
|
||||
inspection.data,
|
||||
inspection.error,
|
||||
inspection.isError,
|
||||
inspection.isPending,
|
||||
isConnected,
|
||||
restoreMutation.error,
|
||||
restoreMutation.isError,
|
||||
restoreMutation.isPending,
|
||||
serverInfo,
|
||||
supportsRecovery,
|
||||
],
|
||||
);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
restoreMutation.mutate();
|
||||
}, [restoreMutation]);
|
||||
const retryInspection = useCallback(() => {
|
||||
void inspection.refetch();
|
||||
}, [inspection]);
|
||||
|
||||
return { state, restore, retryInspection };
|
||||
}
|
||||
Reference in New Issue
Block a user