Preserve opened subagents when parents are archived (#3279)

* fix(subagents): preserve opened children on parent archive

Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.

* fix(subagents): mark generic tab openings

Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.

* fix(subagents): classify cold tab openings

Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.

* fix(app): translate navigation fallback errors

* fix(navigation): allow offline workspace intents

* refactor(app): derive subagent ownership from open tabs

* fix(server): serialize subagent lifecycle ownership
This commit is contained in:
Mohamed Boudra
2026-08-12 21:18:55 +02:00
committed by GitHub
parent fc1cb82e02
commit 4426a6b11f
21 changed files with 696 additions and 70 deletions
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
function tab(agentId: string, createdAt: number): WorkspaceTab {
return { tabId: `agent_${agentId}`, target: { kind: "agent", agentId }, createdAt };
}
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
return {
id: input.id,
parentAgentId: input.parentAgentId ?? null,
labels: input.open ? { [label]: "true" } : {},
} as Agent;
}
describe("getAgentTabsNeedingOpenLabel", () => {
it("marks every present parented tab, regardless of tab order or focus", () => {
const agents = new Map([
["background-child", agent({ id: "background-child", parentAgentId: "parent" })],
["focused-child", agent({ id: "focused-child", parentAgentId: "parent" })],
["root", agent({ id: "root" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("background-child", 1), tab("root", 2), tab("focused-child", 3)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(),
}),
).toEqual(["background-child", "focused-child"]);
});
it("skips tabs already marked or currently being marked", () => {
const agents = new Map([
["marked", agent({ id: "marked", parentAgentId: "parent", open: true })],
["pending", agent({ id: "pending", parentAgentId: "parent" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("marked", 1), tab("pending", 2)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(["pending"]),
}),
).toEqual([]);
});
});
@@ -0,0 +1,25 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
export function getAgentTabsNeedingOpenLabel(input: {
tabs: WorkspaceTab[];
getAgent: (agentId: string) => Agent | null | undefined;
label: string;
pendingAgentIds: ReadonlySet<string>;
}): string[] {
const agentIds = new Set<string>();
for (const tab of input.tabs) {
if (tab.target.kind !== "agent") {
continue;
}
const agent = input.getAgent(tab.target.agentId);
if (
agent?.parentAgentId &&
agent.labels[input.label] !== "true" &&
!input.pendingAgentIds.has(agent.id)
) {
agentIds.add(agent.id);
}
}
return [...agentIds];
}
@@ -0,0 +1,86 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
}
export function useOpenAgentTabLabels(input: {
client: DaemonClient | null;
serverId: string;
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [retryVersion, setRetryVersion] = useState(0);
useEffect(
() => () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
}
},
[],
);
useEffect(() => {
const client = input.client;
if (!client || !input.enabled) {
return;
}
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
for (const agentId of pendingAgentIdsRef.current) {
if (!openAgentIds.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (openAgentIds.size === 0) {
return;
}
void (async () => {
try {
const clientId = await getOrCreateClientId();
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
for (const agentId of agentIds) {
pendingAgentIdsRef.current.add(agentId);
try {
await client.updateAgent(agentId, { labels: { [label]: "true" } });
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to mark open subagent tab", {
error,
agentId,
});
retryTimerRef.current ??= setTimeout(() => {
retryTimerRef.current = null;
setRetryVersion(increment);
}, 2_000);
} finally {
pendingAgentIdsRef.current.delete(agentId);
}
}
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}