mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
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.
This commit is contained in:
@@ -2,7 +2,11 @@ import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import {
|
||||
resolveNavigateToAgent,
|
||||
type HistoryRestoreTarget,
|
||||
type NavigateToAgentInput,
|
||||
} from "./resolve";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export type { NavigateToAgentInput } from "./resolve";
|
||||
@@ -13,34 +17,31 @@ export type { NavigateToAgentInput } from "./resolve";
|
||||
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||
const RESTORE_TIMEOUT_MS = 30000;
|
||||
|
||||
interface PendingWorkspaceRestore {
|
||||
target: HistoryWorkspaceRestore;
|
||||
interface PendingHistoryRestore {
|
||||
target: HistoryRestoreTarget;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
interface HistoryWorkspaceRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
agentArchived: boolean;
|
||||
}
|
||||
const pendingHydrationRestores = new Map<string, PendingHistoryRestore>();
|
||||
const pendingAgentReopens = new Map<string, PendingHistoryRestore>();
|
||||
|
||||
const pendingWorkspaceRestores = new Map<string, PendingWorkspaceRestore>();
|
||||
|
||||
function restoreWhenWorkspacesHydrate(target: HistoryWorkspaceRestore): void {
|
||||
function restoreWhenWorkspacesHydrate(target: HistoryRestoreTarget): void {
|
||||
const { serverId, workspaceId } = target;
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingWorkspaceRestores.get(key);
|
||||
const pending = pendingHydrationRestores.get(key);
|
||||
if (pending) {
|
||||
pending.target = target;
|
||||
return;
|
||||
}
|
||||
|
||||
const restore: PendingWorkspaceRestore = {
|
||||
const restore: PendingHistoryRestore = {
|
||||
target,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
@@ -51,15 +52,50 @@ function restoreWhenWorkspacesHydrate(target: HistoryWorkspaceRestore): void {
|
||||
}
|
||||
|
||||
restore.unsubscribe();
|
||||
pendingWorkspaceRestores.delete(key);
|
||||
pendingHydrationRestores.delete(key);
|
||||
if (session) {
|
||||
restoreArchivedWorkspace(restore.target);
|
||||
restoreHistoryEntry(restore.target);
|
||||
}
|
||||
});
|
||||
pendingWorkspaceRestores.set(key, restore);
|
||||
pendingHydrationRestores.set(key, restore);
|
||||
}
|
||||
|
||||
function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void {
|
||||
function reopenAgentAfterWorkspaceRestore(target: HistoryRestoreTarget): void {
|
||||
const { serverId, workspaceId } = target;
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingAgentReopens.get(key);
|
||||
if (pending) {
|
||||
pending.target = target;
|
||||
return;
|
||||
}
|
||||
|
||||
const restore: PendingHistoryRestore = {
|
||||
target,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
restore.unsubscribe = useSessionStore.subscribe((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
|
||||
restore.unsubscribe();
|
||||
pendingAgentReopens.delete(key);
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
restoreHistoryEntry(restore.target);
|
||||
}
|
||||
});
|
||||
pendingAgentReopens.set(key, restore);
|
||||
}
|
||||
|
||||
function restoreHistoryEntry(target: HistoryRestoreTarget): void {
|
||||
const { serverId, agentId, workspaceId, agentArchived } = target;
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
@@ -72,7 +108,24 @@ function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const liveAgent = session.agents.get(agentId) ?? session.agentDetails.get(agentId);
|
||||
const shouldReopenAgent = agentArchived && (!liveAgent || Boolean(liveAgent.archivedAt));
|
||||
if (!workspaceId) {
|
||||
if (shouldReopenAgent) {
|
||||
client.refreshAgent(agentId).catch((error) => {
|
||||
console.error("[HistoryRestore] Failed to reopen archived agent", {
|
||||
serverId,
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
if (shouldReopenAgent) {
|
||||
reopenAgentAfterWorkspaceRestore(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An empty workspace map is not authoritative during startup. Keep the route on
|
||||
@@ -87,8 +140,6 @@ function restoreArchivedWorkspace(target: HistoryWorkspaceRestore): void {
|
||||
// must restore even when its agent survived unarchived; an archived agent must
|
||||
// still reopen when its workspace survived.
|
||||
if (session.workspaces.has(workspaceId)) {
|
||||
const liveAgent = session.agents.get(agentId) ?? session.agentDetails.get(agentId);
|
||||
const shouldReopenAgent = agentArchived && (!liveAgent || Boolean(liveAgent.archivedAt));
|
||||
if (shouldReopenAgent) {
|
||||
client.refreshAgent(agentId).catch((error) => {
|
||||
console.error("[HistoryRestore] Failed to reopen archived agent", {
|
||||
@@ -136,6 +187,6 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
restoreArchivedWorkspace,
|
||||
restoreHistoryEntry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RecordedTabNav extends NavigateToWorkspaceInput {}
|
||||
interface RecordedRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
workspaceId: string | null;
|
||||
agentArchived: boolean;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
tabNavigations.push(input);
|
||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
||||
},
|
||||
restoreArchivedWorkspace: (input) => {
|
||||
restoreHistoryEntry: (input) => {
|
||||
restores.push(input);
|
||||
},
|
||||
},
|
||||
@@ -147,6 +147,31 @@ describe("resolveNavigateToAgent", () => {
|
||||
expect(hostNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reopens an archived History agent even when it has no workspaceId", () => {
|
||||
const { deps, restores, hostNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent(
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
restoreWorkspace: { agentArchived: true },
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
workspaceId: null,
|
||||
agentArchived: true,
|
||||
},
|
||||
]);
|
||||
expect(hostNavigations).toEqual([{ route: "/h/server-1/agent/agent-1" }]);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -21,16 +21,18 @@ export interface AgentNavTarget {
|
||||
agentWorkspaceId: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface HistoryRestoreTarget {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
agentArchived: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
agentArchived: boolean;
|
||||
}) => void;
|
||||
restoreHistoryEntry: (input: HistoryRestoreTarget) => void;
|
||||
}
|
||||
|
||||
export function resolveNavigateToAgent(
|
||||
@@ -42,14 +44,8 @@ export function resolveNavigateToAgent(
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
deps.navigateToHostAgent(route);
|
||||
return route;
|
||||
}
|
||||
|
||||
if (input.restoreWorkspace) {
|
||||
deps.restoreArchivedWorkspace({
|
||||
deps.restoreHistoryEntry({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
workspaceId,
|
||||
@@ -57,6 +53,12 @@ export function resolveNavigateToAgent(
|
||||
});
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
deps.navigateToHostAgent(route);
|
||||
return route;
|
||||
}
|
||||
|
||||
return deps.navigateToWorkspace({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -25,14 +25,15 @@ import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "remote-server";
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const SECOND_AGENT_ID = "00000000-0000-4000-8000-000000000002";
|
||||
const WORKSPACE_ID = "wks_history_restore";
|
||||
const MISSING_WORKTREE_CWD = "/home/tester/.paseo/worktrees/missing-history-worktree";
|
||||
|
||||
function agent(archivedAt: Date | null): Agent {
|
||||
function agent(archivedAt: Date | null, id = AGENT_ID): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
id,
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
@@ -117,9 +118,13 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
});
|
||||
|
||||
function openFromHistory(entry = { agentArchived: true }): void {
|
||||
openHistoryAgent(AGENT_ID, entry);
|
||||
}
|
||||
|
||||
function openHistoryAgent(agentId: string, entry = { agentArchived: true }): void {
|
||||
const input = {
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
agentId,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
restoreWorkspace: entry,
|
||||
};
|
||||
@@ -150,6 +155,25 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reopens the selected archived agent after another agent restores its workspace", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(SECOND_AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z"), SECOND_AGENT_ID));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
openHistoryAgent(SECOND_AGENT_ID);
|
||||
|
||||
expect(refreshAgent.mock.calls).toEqual([[AGENT_ID]]);
|
||||
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
|
||||
expect(refreshAgent.mock.calls).toEqual([[AGENT_ID], [SECOND_AGENT_ID]]);
|
||||
});
|
||||
|
||||
it("restores a History-opened closed agent whose archived workspace is missing", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
@@ -166,6 +190,26 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("reopens an archived History agent without a workspace identity", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(AGENT_ID);
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
navigateToAgent({
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
restoreWorkspace: { agentArchived: true },
|
||||
});
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not restore during ordinary active-workspace navigation", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
|
||||
Reference in New Issue
Block a user