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:
Mohamed Boudra
2026-07-16 17:22:37 +02:00
committed by GitHub
parent d42ab91971
commit 5da6548aff
47 changed files with 1769 additions and 823 deletions
@@ -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", () => {
-81
View File
@@ -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,
);
}