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
+1
View File
@@ -614,6 +614,7 @@ export const ar: TranslationResources = {
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
+1
View File
@@ -613,6 +613,7 @@ export const en = {
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
confirmations: {
close: "Close",
+1
View File
@@ -619,6 +619,7 @@ export const es: TranslationResources = {
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
+1
View File
@@ -619,6 +619,7 @@ export const fr: TranslationResources = {
reloadingAgent: "Agent de rechargement...",
reloadedAgent: "Agent rechargé",
failedToReloadAgent: "Échec du rechargement de l'agent",
failedToCloseAgent: "Échec de la fermeture de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
+1
View File
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
confirmations: {
unsavedTitle: "未保存の変更",
+1
View File
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
confirmations: {
close: "닫기",
+1
View File
@@ -618,6 +618,7 @@ export const ptBR: TranslationResources = {
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
+1
View File
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Перезагруженный агент",
failedToReloadAgent: "Не удалось перезагрузить агент",
failedToCloseAgent: "Не удалось закрыть агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
+1
View File
@@ -614,6 +614,7 @@ export const zhCN: TranslationResources = {
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
@@ -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,
@@ -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]);
}