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
@@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => {
]);
expect(groups).toEqual({
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
layoutOnlyAgentTabs: [],
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
otherTabs: [
{
@@ -53,6 +54,21 @@ describe("workspace bulk close helpers", () => {
});
});
it("separates subagent tabs from agents that archive on close", () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
expect(groups).toMatchObject({
archiveAgentTabs: [{ tabId: "agent_root", agentId: "root" }],
layoutOnlyAgentTabs: [{ tabId: "agent_child", agentId: "child" }],
});
expect(buildBulkCloseConfirmationMessage(groups)).toBe(
"This will archive 1 agent(s) and close 1 tab(s).",
);
});
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
const message = buildBulkCloseConfirmationMessage(
classifyBulkClosableTabs([
@@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
logLabel: "all tabs",
});
@@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
warn,
logLabel: "others",
});
@@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => {
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]);
});
it("closes subagent tabs without sending them to closeItems", async () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
const closeItems = vi.fn(async () => ({ agents: [], terminals: [], requestId: "req-1" }));
const preparedSubagents: string[] = [];
const cleanedTabs: string[] = [];
await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (_tabId, action) => action(),
closeLayoutOnlyAgent: async (agentId) => {
preparedSubagents.push(agentId);
},
closeWorkspaceTabWithCleanup: ({ tabId }) => {
cleanedTabs.push(tabId);
},
logLabel: "others",
});
expect(closeItems).toHaveBeenCalledWith({ agentIds: ["root"], terminalIds: [] });
expect(preparedSubagents).toEqual(["child"]);
expect(cleanedTabs).toEqual(["agent_child", "agent_root"]);
});
});
@@ -3,7 +3,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import { i18n } from "@/i18n/i18next";
export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>;
archiveAgentTabs: Array<{ tabId: string; agentId: string }>;
layoutOnlyAgentTabs: Array<{ tabId: string; agentId: string }>;
terminalTabs: Array<{ tabId: string; terminalId: string }>;
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
}
@@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput {
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
closeLayoutOnlyAgent: (agentId: string) => Promise<void>;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
export function classifyBulkClosableTabs(
tabs: WorkspaceTabDescriptor[],
resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive",
): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
archiveAgentTabs: [],
layoutOnlyAgentTabs: [],
terminalTabs: [],
otherTabs: [],
};
for (const tab of tabs) {
if (tab.target.kind === "agent") {
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId };
if (resolveAgentCloseKind(tab.target.agentId) === "layout-only") {
groups.layoutOnlyAgentTabs.push(agentTab);
} else {
groups.archiveAgentTabs.push(agentTab);
}
continue;
}
if (tab.target.kind === "terminal") {
@@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage(
input: BulkClosableTabGroups,
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
): string {
const { agentTabs, terminalTabs, otherTabs } = input;
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
return labels.all({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && terminalTabs.length > 0) {
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) {
return labels.agentsAndTerminals({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
});
}
if (terminalTabs.length > 0 && otherTabs.length > 0) {
if (terminalTabs.length > 0 && tabCount > 0) {
return labels.terminalsAndTabs({
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && otherTabs.length > 0) {
if (archiveAgentTabs.length > 0 && tabCount > 0) {
return labels.agentsAndTabs({
agents: agentTabs.length,
tabs: otherTabs.length,
agents: archiveAgentTabs.length,
tabs: tabCount,
});
}
if (terminalTabs.length > 0) {
return labels.terminals({ terminals: terminalTabs.length });
}
if (otherTabs.length > 0) {
return labels.tabs({ tabs: otherTabs.length });
if (tabCount > 0) {
return labels.tabs({ tabs: tabCount });
}
return labels.agents({ agents: agentTabs.length });
return labels.agents({ agents: archiveAgentTabs.length });
}
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
const {
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup,
closeLayoutOnlyAgent,
logLabel,
warn,
} = input;
const hasDestructiveTabs = groups.archiveAgentTabs.length > 0 || groups.terminalTabs.length > 0;
for (const { tabId, agentId } of groups.layoutOnlyAgentTabs) {
await closeTab(tabId, async () => {
try {
await closeLayoutOnlyAgent(agentId);
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to close subagent tab ${logLabel}`, { error, agentId });
return;
}
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
if (hasDestructiveTabs && client) {
void client
.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
})
.catch((error) => {
@@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
});
}
for (const { tabId, agentId } of groups.agentTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId, target } of groups.otherTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId, target });
});
}
await Promise.all([
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
}),
),
...groups.terminalTabs.map(({ tabId, terminalId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}),
),
...groups.otherTabs.map(({ tabId, target }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId, target });
}),
),
]);
}
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
memo,
useCallback,
@@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
@@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
);
useOpenAgentTabLabels({
client,
serverId: normalizedServerId,
tabs: uiTabs,
enabled: hasHydratedWorkspaceLayoutStore,
});
useSyncWorkspaceActiveBrowser({
workspaceLayout,
isRouteFocused,
@@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const closePolicy = resolveCloseAgentTabPolicy(agent);
let closePolicy = resolveCloseAgentTabPolicy(agent);
const isRunning = agent?.status === "running";
if (isRunning && closePolicy.kind === "archive-on-close") {
@@ -2667,6 +2676,27 @@ function WorkspaceScreenContent({
}
}
if (closePolicy.kind === "layout-only") {
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
if (!sessionClient) {
toast.error(t("common.errors.daemonClientUnavailable"));
return;
}
try {
const clientId = await getOrCreateClientId();
await sessionClient.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
} catch (error) {
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
toast.error(t("workspace.tabs.toasts.failedToCloseAgent"));
return;
}
}
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
[
archiveAgent,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
persistenceKey,
t,
toast,
],
);
const handleClosePassiveTab = useCallback(
@@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({
return;
}
const groups = classifyBulkClosableTabs(tabsToClose);
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
const modifiedCount = tabsToClose.filter(
(tab) =>
getPanelInstanceAttributes({
@@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({
client,
groups,
closeTab,
closeLayoutOnlyAgent: async (agentId) => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const clientId = await getOrCreateClientId();
await client.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
await archiveAgent({ serverId: normalizedServerId, agentId });
}
},
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
@@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
bulkCloseConfirmationLabels,
client,
closeTab,