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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user