Archive finished subagents across the whole track (#3368)
* feat(app): archive finished subagents together Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local. * test(app): clarify subagent archive assertions
This commit is contained in:
@@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
|||||||
|
|
||||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||||
|
|
||||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
|
||||||
|
|
||||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
|||||||
|
|
||||||
### Subagent accumulation under long-lived parents
|
### Subagent accumulation under long-lived parents
|
||||||
|
|
||||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
|
||||||
|
|
||||||
### Cross-client tab dismissal
|
### Cross-client tab dismissal
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
archiveFinishedSubagents,
|
||||||
|
expectArchiveFinishedInProgress,
|
||||||
|
expectArchiveFinishedRetry,
|
||||||
|
expectManagedSubagentArchived,
|
||||||
|
expectManagedSubagentUnarchived,
|
||||||
|
expectSubagentRowGone,
|
||||||
|
expectSubagentRowVisible,
|
||||||
|
holdManagedSubagentArchiveRequest,
|
||||||
|
openSubagentsTrack,
|
||||||
|
rejectNextManagedSubagentArchiveRequest,
|
||||||
|
seedParentWithSubagent,
|
||||||
|
} from "../support/helpers/subagents";
|
||||||
|
|
||||||
|
test.describe("Archive finished subagents", () => {
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await workspace?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archives a finished managed child from the track", async ({ page }) => {
|
||||||
|
const agents = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Archive parent",
|
||||||
|
childTitle: "Finished child",
|
||||||
|
});
|
||||||
|
await workspace.client.waitForAgentUpsert(
|
||||||
|
agents.child.id,
|
||||||
|
(snapshot) => snapshot.status === "idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
|
||||||
|
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await expectSubagentRowVisible(page, agents.child.id);
|
||||||
|
|
||||||
|
await archiveFinishedSubagents(page);
|
||||||
|
await archiveGate.waitForRequest();
|
||||||
|
|
||||||
|
await expectSubagentRowGone(page, agents.child.id);
|
||||||
|
await expectArchiveFinishedInProgress(page, 0, 1);
|
||||||
|
await expectManagedSubagentUnarchived(workspace, agents.child.id);
|
||||||
|
archiveGate.release();
|
||||||
|
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores a failed child and retries the archive", async ({ page }) => {
|
||||||
|
const agents = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Retry parent",
|
||||||
|
childTitle: "Retry child",
|
||||||
|
});
|
||||||
|
await workspace.client.waitForAgentUpsert(
|
||||||
|
agents.child.id,
|
||||||
|
(snapshot) => snapshot.status === "idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
|
||||||
|
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await expectSubagentRowVisible(page, agents.child.id);
|
||||||
|
|
||||||
|
await archiveFinishedSubagents(page);
|
||||||
|
await rejection.waitForRejection();
|
||||||
|
|
||||||
|
await expectSubagentRowVisible(page, agents.child.id);
|
||||||
|
await expectArchiveFinishedRetry(page, 1, 1);
|
||||||
|
await archiveFinishedSubagents(page);
|
||||||
|
|
||||||
|
await expectSubagentRowGone(page, agents.child.id);
|
||||||
|
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
import type { SeededWorkspace } from "./seed-client";
|
import type { SeededWorkspace } from "./seed-client";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
export interface ManagedSubagentArchiveGate {
|
||||||
|
release(): void;
|
||||||
|
waitForRequest(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedSubagentArchiveRejection {
|
||||||
|
waitForRejection(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SeededSubagentPair {
|
export interface SeededSubagentPair {
|
||||||
parent: {
|
parent: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectManagedSubagentUnarchived(
|
||||||
|
workspace: Pick<SeededWorkspace, "client">,
|
||||||
|
childId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
|
||||||
|
agent: { id: childId, archivedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectManagedSubagentArchived(
|
||||||
|
workspace: Pick<SeededWorkspace, "client">,
|
||||||
|
childId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
|
||||||
|
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveFinishedSubagents(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Archive finished subagents" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectArchiveFinishedInProgress(
|
||||||
|
page: Page,
|
||||||
|
completed: number,
|
||||||
|
total: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
|
||||||
|
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectArchiveFinishedRetry(
|
||||||
|
page: Page,
|
||||||
|
failed: number,
|
||||||
|
total: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function holdManagedSubagentArchiveRequest(
|
||||||
|
page: Page,
|
||||||
|
subagentId: string,
|
||||||
|
): Promise<ManagedSubagentArchiveGate> {
|
||||||
|
let released = false;
|
||||||
|
let resolveRequest: (() => void) | undefined;
|
||||||
|
const delayedForwards: Array<() => void> = [];
|
||||||
|
const request = new Promise<void>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
|
||||||
|
delayedForwards.push(() => server.send(message));
|
||||||
|
resolveRequest?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
server.onMessage((message) => ws.send(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
release() {
|
||||||
|
released = true;
|
||||||
|
for (const forward of delayedForwards.splice(0)) forward();
|
||||||
|
},
|
||||||
|
waitForRequest: () => request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectNextManagedSubagentArchiveRequest(
|
||||||
|
page: Page,
|
||||||
|
subagentId: string,
|
||||||
|
): Promise<ManagedSubagentArchiveRejection> {
|
||||||
|
let resolveRejection: (() => void) | undefined;
|
||||||
|
const rejection = new Promise<void>((resolve) => {
|
||||||
|
resolveRejection = resolve;
|
||||||
|
});
|
||||||
|
let rejected = false;
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
const requestId = archiveRequestId(message, subagentId);
|
||||||
|
if (requestId && !rejected) {
|
||||||
|
rejected = true;
|
||||||
|
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||||
|
// correlated error travels back through the real browser client and mutation rollback.
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
requestType: "archive_agent_request",
|
||||||
|
error: "Archive rejected for test",
|
||||||
|
code: "archive_rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
resolveRejection?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
server.onMessage((message) => ws.send(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { waitForRejection: () => rejection };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||||
|
return archiveRequestId(message, subagentId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
||||||
|
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(rawMessage) as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
envelope.type === "session" &&
|
||||||
|
envelope.message?.type === "archive_agent_request" &&
|
||||||
|
envelope.message.agentId === subagentId &&
|
||||||
|
typeof envelope.message.requestId === "string"
|
||||||
|
) {
|
||||||
|
return envelope.message.requestId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -1618,6 +1618,7 @@ export const ar: TranslationResources = {
|
|||||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||||
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
||||||
archiveFinishedTooltip: "أرشفة المكتملين",
|
archiveFinishedTooltip: "أرشفة المكتملين",
|
||||||
|
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1628,6 +1628,7 @@ export const en = {
|
|||||||
archiveTooltip: "Archive subagent",
|
archiveTooltip: "Archive subagent",
|
||||||
archiveFinishedAction: "Archive finished subagents",
|
archiveFinishedAction: "Archive finished subagents",
|
||||||
archiveFinishedTooltip: "Archive finished",
|
archiveFinishedTooltip: "Archive finished",
|
||||||
|
archiveFinishedRetry: "Retry ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1661,6 +1661,7 @@ export const es: TranslationResources = {
|
|||||||
archiveTooltip: "Subagente de archivo",
|
archiveTooltip: "Subagente de archivo",
|
||||||
archiveFinishedAction: "Archivar subagentes finalizados",
|
archiveFinishedAction: "Archivar subagentes finalizados",
|
||||||
archiveFinishedTooltip: "Archivar finalizados",
|
archiveFinishedTooltip: "Archivar finalizados",
|
||||||
|
archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1665,6 +1665,7 @@ export const fr: TranslationResources = {
|
|||||||
archiveTooltip: "Sous-agent d'archivage",
|
archiveTooltip: "Sous-agent d'archivage",
|
||||||
archiveFinishedAction: "Archiver les sous-agents terminés",
|
archiveFinishedAction: "Archiver les sous-agents terminés",
|
||||||
archiveFinishedTooltip: "Archiver les terminés",
|
archiveFinishedTooltip: "Archiver les terminés",
|
||||||
|
archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1634,6 +1634,7 @@ export const ja: TranslationResources = {
|
|||||||
archiveTooltip: "サブエージェントをアーカイブ",
|
archiveTooltip: "サブエージェントをアーカイブ",
|
||||||
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
||||||
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
||||||
|
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1628,6 +1628,7 @@ export const ko: TranslationResources = {
|
|||||||
archiveTooltip: "서브에이전트 보관",
|
archiveTooltip: "서브에이전트 보관",
|
||||||
archiveFinishedAction: "완료된 하위 에이전트 보관",
|
archiveFinishedAction: "완료된 하위 에이전트 보관",
|
||||||
archiveFinishedTooltip: "아카이브 완료",
|
archiveFinishedTooltip: "아카이브 완료",
|
||||||
|
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1647,6 +1647,7 @@ export const ptBR: TranslationResources = {
|
|||||||
archiveTooltip: "Arquivar subagente",
|
archiveTooltip: "Arquivar subagente",
|
||||||
archiveFinishedAction: "Arquivar subagentes concluídos",
|
archiveFinishedAction: "Arquivar subagentes concluídos",
|
||||||
archiveFinishedTooltip: "Arquivar concluídos",
|
archiveFinishedTooltip: "Arquivar concluídos",
|
||||||
|
archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1652,6 +1652,7 @@ export const ru: TranslationResources = {
|
|||||||
archiveTooltip: "Архивный субагент",
|
archiveTooltip: "Архивный субагент",
|
||||||
archiveFinishedAction: "Архивировать завершенные субагенты",
|
archiveFinishedAction: "Архивировать завершенные субагенты",
|
||||||
archiveFinishedTooltip: "Архивировать завершенные",
|
archiveFinishedTooltip: "Архивировать завершенные",
|
||||||
|
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1598,6 +1598,7 @@ export const zhCN: TranslationResources = {
|
|||||||
archiveTooltip: "归档 subagent",
|
archiveTooltip: "归档 subagent",
|
||||||
archiveFinishedAction: "归档已完成的 subagent",
|
archiveFinishedAction: "归档已完成的 subagent",
|
||||||
archiveFinishedTooltip: "归档已完成项",
|
archiveFinishedTooltip: "归档已完成项",
|
||||||
|
archiveFinishedRetry: "重试 ({{failed}}/{{total}})",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
useHideFinishedProviderSubagents,
|
useArchiveFinishedSubagents,
|
||||||
useArchiveSubagent,
|
useArchiveSubagent,
|
||||||
useDetachSubagent,
|
useDetachSubagent,
|
||||||
useSubagentsForParent,
|
useSubagentsForParent,
|
||||||
@@ -1532,9 +1532,10 @@ function ActiveAgentComposer({
|
|||||||
);
|
);
|
||||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||||
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||||
serverId,
|
serverId,
|
||||||
parentAgentId: agentId,
|
parentAgentId: agentId,
|
||||||
|
rows: subagentRows,
|
||||||
});
|
});
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1628,7 +1629,8 @@ function ActiveAgentComposer({
|
|||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||||
onArchiveSubagent={handleArchiveSubagent}
|
onArchiveSubagent={handleArchiveSubagent}
|
||||||
onArchiveFinished={handleHideFinishedProviderSubagents}
|
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||||
|
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||||
/>
|
/>
|
||||||
<Composer
|
<Composer
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished";
|
||||||
|
import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
|
||||||
|
|
||||||
|
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
|
||||||
|
return {
|
||||||
|
kind: "paseo",
|
||||||
|
id,
|
||||||
|
provider: "codex",
|
||||||
|
title: id,
|
||||||
|
description: null,
|
||||||
|
subtitle: null,
|
||||||
|
status,
|
||||||
|
requiresAttention: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(
|
||||||
|
id: string,
|
||||||
|
status: ProviderSubagentRow["status"] = "completed",
|
||||||
|
): ProviderSubagentRow {
|
||||||
|
return {
|
||||||
|
kind: "provider",
|
||||||
|
id,
|
||||||
|
parentAgentId: "parent",
|
||||||
|
provider: "codex",
|
||||||
|
title: id,
|
||||||
|
description: null,
|
||||||
|
subtitle: null,
|
||||||
|
status,
|
||||||
|
requiresAttention: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managed(
|
||||||
|
id: string,
|
||||||
|
status: ManagedSubagentSnapshot["status"] = "idle",
|
||||||
|
overrides: Partial<ManagedSubagentSnapshot> = {},
|
||||||
|
): ManagedSubagentSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
parentAgentId: "parent",
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createArchiveFinishedSubagents", () => {
|
||||||
|
it("dismisses provider rows before sequential managed archives and reports combined progress", async () => {
|
||||||
|
const first = deferred<void>();
|
||||||
|
const second = deferred<void>();
|
||||||
|
const current = new Map([
|
||||||
|
["first", managed("first")],
|
||||||
|
["second", managed("second")],
|
||||||
|
["resumed", managed("resumed", "running")],
|
||||||
|
]);
|
||||||
|
const events: string[] = [];
|
||||||
|
const archive = createArchiveFinishedSubagents(
|
||||||
|
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
|
||||||
|
{
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: (id) => {
|
||||||
|
events.push(`archive:${id}`);
|
||||||
|
return id === "first" ? first.promise : second.promise;
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: (ids) => events.push(`dismiss:${ids.join(",")}`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const progress: string[] = [];
|
||||||
|
archive.subscribe(() => {
|
||||||
|
const status = archive.getState().status;
|
||||||
|
if (status.kind === "archiving") {
|
||||||
|
progress.push(`${status.completedCount}/${status.totalCount}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = archive.archiveFinished();
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 4,
|
||||||
|
status: { kind: "archiving", completedCount: 1, totalCount: 4 },
|
||||||
|
});
|
||||||
|
expect(events).toEqual(["dismiss:native", "archive:first"]);
|
||||||
|
|
||||||
|
first.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 4,
|
||||||
|
status: { kind: "archiving", completedCount: 2, totalCount: 4 },
|
||||||
|
});
|
||||||
|
expect(events).toEqual(["dismiss:native", "archive:first", "archive:second"]);
|
||||||
|
|
||||||
|
second.resolve();
|
||||||
|
await expect(operation).resolves.toEqual({
|
||||||
|
archivedPaseoIds: ["first", "second"],
|
||||||
|
dismissedProviderIds: ["native"],
|
||||||
|
skippedPaseoIds: ["resumed"],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]);
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 4, status: { kind: "idle" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains failure through optimistic rollback, retries current rows, and resets for a later eligible-set change", async () => {
|
||||||
|
const first = deferred<void>();
|
||||||
|
const second = deferred<void>();
|
||||||
|
const retry = deferred<void>();
|
||||||
|
const current = new Map([
|
||||||
|
["first", managed("first")],
|
||||||
|
["second", managed("second")],
|
||||||
|
]);
|
||||||
|
const responses = [first.promise, second.promise, retry.promise];
|
||||||
|
const calls: string[] = [];
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: (id) => {
|
||||||
|
calls.push(id);
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) throw new Error("Unexpected archive");
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAttempt = archive.archiveFinished();
|
||||||
|
archive.setRows([paseo("second")]);
|
||||||
|
const firstError = new Error("first failed");
|
||||||
|
first.reject(firstError);
|
||||||
|
await Promise.resolve();
|
||||||
|
second.resolve();
|
||||||
|
archive.setRows([]);
|
||||||
|
await expect(firstAttempt).resolves.toEqual({
|
||||||
|
archivedPaseoIds: ["second"],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [{ id: "first", error: firstError }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 0,
|
||||||
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
|
});
|
||||||
|
archive.setRows([paseo("first")]);
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 1,
|
||||||
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
|
});
|
||||||
|
expect(calls).toEqual(["first", "second"]);
|
||||||
|
|
||||||
|
archive.setRows([paseo("first", "error")]);
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 1,
|
||||||
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryAttempt = archive.archiveFinished();
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 1,
|
||||||
|
status: { kind: "archiving", completedCount: 0, totalCount: 1 },
|
||||||
|
});
|
||||||
|
expect(calls).toEqual(["first", "second", "first"]);
|
||||||
|
const retryError = new Error("retry failed");
|
||||||
|
retry.reject(retryError);
|
||||||
|
await expect(retryAttempt).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [{ id: "first", error: retryError }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 1,
|
||||||
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
|
});
|
||||||
|
archive.setRows([paseo("first"), paseo("later")]);
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 2, status: { kind: "idle" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retained failure for eligible status changes and row reorder, then resets on membership change", async () => {
|
||||||
|
const firstError = new Error("first failed");
|
||||||
|
const secondError = new Error("second failed");
|
||||||
|
const current = new Map([
|
||||||
|
["first", managed("first")],
|
||||||
|
["second", managed("second")],
|
||||||
|
]);
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async (id) => {
|
||||||
|
throw id === "first" ? firstError : secondError;
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = archive.archiveFinished();
|
||||||
|
archive.setRows([]);
|
||||||
|
await operation;
|
||||||
|
archive.setRows([paseo("first"), paseo("second")]);
|
||||||
|
|
||||||
|
archive.setRows([paseo("second", "error"), paseo("first", "error")]);
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 2,
|
||||||
|
status: { kind: "failed", failedCount: 2, totalCount: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.setRows([paseo("second", "error")]);
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears retry state when the authoritative failed child is running", async () => {
|
||||||
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async () => {
|
||||||
|
current.set("failed", managed("failed", "running"));
|
||||||
|
throw new Error("archive failed");
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = archive.archiveFinished();
|
||||||
|
archive.setRows([]);
|
||||||
|
await operation;
|
||||||
|
archive.setRows([paseo("failed", "running")]);
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears retry state when the authoritative failed child is initializing", async () => {
|
||||||
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async () => {
|
||||||
|
current.set("failed", managed("failed", "initializing"));
|
||||||
|
throw new Error("archive failed");
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = archive.archiveFinished();
|
||||||
|
archive.setRows([]);
|
||||||
|
await operation;
|
||||||
|
archive.setRows([paseo("failed", "initializing")]);
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["is missing", (current: Map<string, ManagedSubagentSnapshot>) => current.delete("failed")],
|
||||||
|
[
|
||||||
|
"is archived",
|
||||||
|
(current: Map<string, ManagedSubagentSnapshot>) =>
|
||||||
|
current.set("failed", managed("failed", "idle", { archivedAt: new Date() })),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"belongs to a different parent",
|
||||||
|
(current: Map<string, ManagedSubagentSnapshot>) =>
|
||||||
|
current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })),
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"clears retry state when the authoritative failed child %s",
|
||||||
|
async (_label, updateSnapshot) => {
|
||||||
|
const error = new Error("archive failed");
|
||||||
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async () => {
|
||||||
|
updateSnapshot(current);
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [{ id: "failed", error }],
|
||||||
|
});
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("retains only eligible rolled-back failures and retries only those children", async () => {
|
||||||
|
const firstError = new Error("first failed");
|
||||||
|
const secondError = new Error("second failed");
|
||||||
|
const current = new Map([
|
||||||
|
["first", managed("first")],
|
||||||
|
["second", managed("second")],
|
||||||
|
]);
|
||||||
|
const calls: string[] = [];
|
||||||
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async (id) => {
|
||||||
|
calls.push(id);
|
||||||
|
if (calls.length === 1) throw firstError;
|
||||||
|
if (calls.length === 2) {
|
||||||
|
current.set("second", managed("second", "running"));
|
||||||
|
throw secondError;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = archive.archiveFinished();
|
||||||
|
archive.setRows([]);
|
||||||
|
await expect(operation).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [
|
||||||
|
{ id: "first", error: firstError },
|
||||||
|
{ id: "second", error: secondError },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
archive.setRows([paseo("first"), paseo("second", "running")]);
|
||||||
|
|
||||||
|
expect(archive.getState()).toEqual({
|
||||||
|
eligibleCount: 1,
|
||||||
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
|
});
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: ["first"],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
expect(calls).toEqual(["first", "second", "first"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechecks each managed child and skips resumed, missing, archived, reparented, running, and initializing rows", async () => {
|
||||||
|
const rows = [
|
||||||
|
paseo("first"),
|
||||||
|
paseo("resumed"),
|
||||||
|
paseo("missing"),
|
||||||
|
paseo("archived"),
|
||||||
|
paseo("reparented"),
|
||||||
|
paseo("running"),
|
||||||
|
paseo("initializing"),
|
||||||
|
];
|
||||||
|
const current = new Map<string, ManagedSubagentSnapshot>([
|
||||||
|
["first", managed("first")],
|
||||||
|
["resumed", managed("resumed")],
|
||||||
|
["archived", managed("archived", "idle", { archivedAt: new Date() })],
|
||||||
|
["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })],
|
||||||
|
["running", managed("running", "running")],
|
||||||
|
["initializing", managed("initializing", "initializing")],
|
||||||
|
]);
|
||||||
|
const archived: string[] = [];
|
||||||
|
const archive = createArchiveFinishedSubagents(rows, {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: (id) => current.get(id),
|
||||||
|
archiveManagedSubagent: async (id) => {
|
||||||
|
archived.push(id);
|
||||||
|
current.set("resumed", managed("resumed", "running"));
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: ["first"],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(archived).toEqual(["first"]);
|
||||||
|
expect(archive.getState()).toEqual({ eligibleCount: 7, status: { kind: "idle" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses finished provider rows locally without removing their descriptors", async () => {
|
||||||
|
const descriptors = new Map([
|
||||||
|
["finished", provider("finished")],
|
||||||
|
["running", provider("running", "running")],
|
||||||
|
]);
|
||||||
|
const dismissed: string[][] = [];
|
||||||
|
const archive = createArchiveFinishedSubagents([...descriptors.values()], {
|
||||||
|
parentAgentId: "parent",
|
||||||
|
getManagedSubagent: () => undefined,
|
||||||
|
archiveManagedSubagent: async () => undefined,
|
||||||
|
dismissProviderSubagents: (ids) => dismissed.push(ids),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: ["finished"],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dismissed).toEqual([["finished"]]);
|
||||||
|
expect(descriptors.get("finished")).toEqual(provider("finished"));
|
||||||
|
expect(descriptors.get("running")).toEqual(provider("running", "running"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import type { SubagentRow } from "./select";
|
||||||
|
|
||||||
|
export type ArchiveFinishedStatus =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "archiving"; completedCount: number; totalCount: number }
|
||||||
|
| { kind: "failed"; failedCount: number; totalCount: number };
|
||||||
|
|
||||||
|
export interface ArchiveFinishedState {
|
||||||
|
eligibleCount: number;
|
||||||
|
status: ArchiveFinishedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveFinishedOutcome {
|
||||||
|
archivedPaseoIds: string[];
|
||||||
|
dismissedProviderIds: string[];
|
||||||
|
skippedPaseoIds: string[];
|
||||||
|
failures: Array<{ id: string; error: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgentId" | "archivedAt">;
|
||||||
|
|
||||||
|
export interface ArchiveFinishedSubagentsDeps {
|
||||||
|
parentAgentId: string;
|
||||||
|
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
|
||||||
|
archiveManagedSubagent: (id: string) => Promise<void>;
|
||||||
|
dismissProviderSubagents: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveFinishedSubagents {
|
||||||
|
getState(): ArchiveFinishedState;
|
||||||
|
setRows(rows: readonly SubagentRow[]): void;
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
archiveFinished(): Promise<ArchiveFinishedOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFinishedSubagent(row: SubagentRow): boolean {
|
||||||
|
if (row.kind === "paseo") return row.status === "idle" || row.status === "error";
|
||||||
|
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canArchiveManagedSubagent(
|
||||||
|
agent: ManagedSubagentSnapshot | undefined,
|
||||||
|
parentAgentId: string,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
agent &&
|
||||||
|
!agent.archivedAt &&
|
||||||
|
agent.parentAgentId === parentAgentId &&
|
||||||
|
(agent.status === "idle" || agent.status === "error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eligibleSignature(rows: readonly SubagentRow[]): string {
|
||||||
|
return identitySignature(rowIdentities(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedRowIdentity(id: string): string {
|
||||||
|
return `paseo:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowIdentity(row: SubagentRow): string {
|
||||||
|
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
|
||||||
|
return new Set(rows.map(rowIdentity));
|
||||||
|
}
|
||||||
|
|
||||||
|
function identitySignature(ids: ReadonlySet<string>): string {
|
||||||
|
return [...ids].sort().join("\0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyOutcome(): ArchiveFinishedOutcome {
|
||||||
|
return {
|
||||||
|
archivedPaseoIds: [],
|
||||||
|
dismissedProviderIds: [],
|
||||||
|
skippedPaseoIds: [],
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArchiveFinishedSubagents(
|
||||||
|
initialRows: readonly SubagentRow[],
|
||||||
|
deps: ArchiveFinishedSubagentsDeps,
|
||||||
|
): ArchiveFinishedSubagents {
|
||||||
|
let eligibleRows = initialRows.filter(isFinishedSubagent);
|
||||||
|
let signature = eligibleSignature(eligibleRows);
|
||||||
|
let failedSignature: string | null = null;
|
||||||
|
let state: ArchiveFinishedState = {
|
||||||
|
eligibleCount: eligibleRows.length,
|
||||||
|
status: { kind: "idle" },
|
||||||
|
};
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const publish = () => {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
};
|
||||||
|
const setState = (next: ArchiveFinishedState) => {
|
||||||
|
state = next;
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRows = (rows: readonly SubagentRow[]) => {
|
||||||
|
const nextEligibleRows = rows.filter(isFinishedSubagent);
|
||||||
|
const nextSignature = eligibleSignature(nextEligibleRows);
|
||||||
|
const eligibilityChanged = signature !== nextSignature;
|
||||||
|
eligibleRows = nextEligibleRows;
|
||||||
|
signature = nextSignature;
|
||||||
|
|
||||||
|
if (state.status.kind === "failed" && failedSignature !== signature) {
|
||||||
|
failedSignature = null;
|
||||||
|
setState({ eligibleCount: eligibleRows.length, status: { kind: "idle" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.eligibleCount !== eligibleRows.length || eligibilityChanged) {
|
||||||
|
setState({ ...state, eligibleCount: eligibleRows.length });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const archiveFinished = () => {
|
||||||
|
if (state.status.kind === "archiving" || eligibleRows.length === 0) {
|
||||||
|
return Promise.resolve(emptyOutcome());
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = eligibleRows;
|
||||||
|
const totalCount = snapshot.length;
|
||||||
|
setState({
|
||||||
|
eligibleCount: eligibleRows.length,
|
||||||
|
status: { kind: "archiving", completedCount: 0, totalCount },
|
||||||
|
});
|
||||||
|
return runArchiveFinished(snapshot, deps, (completedCount) => {
|
||||||
|
setState({
|
||||||
|
eligibleCount: eligibleRows.length,
|
||||||
|
status: { kind: "archiving", completedCount, totalCount },
|
||||||
|
});
|
||||||
|
}).then(({ outcome, retryableFailureIds }) => {
|
||||||
|
failedSignature =
|
||||||
|
retryableFailureIds.size > 0 ? identitySignature(retryableFailureIds) : null;
|
||||||
|
setState({
|
||||||
|
eligibleCount: eligibleRows.length,
|
||||||
|
status:
|
||||||
|
retryableFailureIds.size > 0
|
||||||
|
? {
|
||||||
|
kind: "failed",
|
||||||
|
failedCount: retryableFailureIds.size,
|
||||||
|
totalCount: retryableFailureIds.size,
|
||||||
|
}
|
||||||
|
: { kind: "idle" },
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState: () => state,
|
||||||
|
setRows,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
archiveFinished,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runArchiveFinished(
|
||||||
|
rows: readonly SubagentRow[],
|
||||||
|
deps: ArchiveFinishedSubagentsDeps,
|
||||||
|
reportProgress: (completedCount: number) => void,
|
||||||
|
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
|
||||||
|
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
|
||||||
|
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
|
||||||
|
let completedCount = 0;
|
||||||
|
const outcome = emptyOutcome();
|
||||||
|
const retryableFailureIds = new Set<string>();
|
||||||
|
|
||||||
|
if (providerIds.length > 0) {
|
||||||
|
deps.dismissProviderSubagents(providerIds);
|
||||||
|
outcome.dismissedProviderIds.push(...providerIds);
|
||||||
|
completedCount += providerIds.length;
|
||||||
|
reportProgress(completedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of paseoIds) {
|
||||||
|
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
||||||
|
try {
|
||||||
|
await deps.archiveManagedSubagent(id);
|
||||||
|
outcome.archivedPaseoIds.push(id);
|
||||||
|
} catch (error) {
|
||||||
|
outcome.failures.push({ id, error });
|
||||||
|
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
||||||
|
retryableFailureIds.add(managedRowIdentity(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outcome.skippedPaseoIds.push(id);
|
||||||
|
}
|
||||||
|
completedCount += 1;
|
||||||
|
reportProgress(completedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outcome, retryableFailureIds };
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ export type { SubagentRow } from "./select";
|
|||||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||||
export {
|
|
||||||
useHideFinishedProviderSubagents,
|
|
||||||
type UseHideFinishedProviderSubagentsInput,
|
|
||||||
} from "./use-hide-finished-provider-subagents";
|
|
||||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||||
|
export {
|
||||||
|
useArchiveFinishedSubagents,
|
||||||
|
type ArchiveFinishedStatus,
|
||||||
|
type UseArchiveFinishedSubagentsInput,
|
||||||
|
} from "./use-archive-finished";
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ describe("provider subagent client store", () => {
|
|||||||
item: { type: "assistant_message", text: "Finished output." },
|
item: { type: "assistant_message", text: "Finished output." },
|
||||||
});
|
});
|
||||||
|
|
||||||
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
|
||||||
|
|
||||||
const state = useProviderSubagentStore.getState();
|
const state = useProviderSubagentStore.getState();
|
||||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
@@ -185,7 +185,7 @@ describe("provider subagent client store", () => {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
};
|
};
|
||||||
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
||||||
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
|
||||||
store.replaceList(SERVER_ID, PARENT_ID, [completed]);
|
store.replaceList(SERVER_ID, PARENT_ID, [completed]);
|
||||||
|
|
||||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
@@ -215,7 +215,7 @@ describe("provider subagent client store", () => {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
|
||||||
|
|
||||||
store.replaceList(SERVER_ID, PARENT_ID, []);
|
store.replaceList(SERVER_ID, PARENT_ID, []);
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ describe("provider subagent client store", () => {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
};
|
};
|
||||||
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
||||||
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
|
||||||
store.applyUpdate(SERVER_ID, {
|
store.applyUpdate(SERVER_ID, {
|
||||||
kind: "remove",
|
kind: "remove",
|
||||||
parentAgentId: PARENT_ID,
|
parentAgentId: PARENT_ID,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface ProviderSubagentState {
|
|||||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||||
hiddenFromTrack: Set<string>;
|
hiddenFromTrack: Set<string>;
|
||||||
hideFinishedForParent(serverId: string, parentAgentId: string): void;
|
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
|
||||||
replaceList(
|
replaceList(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
parentAgentId: string,
|
parentAgentId: string,
|
||||||
@@ -196,14 +196,12 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
|||||||
descriptors: new Map(),
|
descriptors: new Map(),
|
||||||
timelines: new Map(),
|
timelines: new Map(),
|
||||||
hiddenFromTrack: new Set(),
|
hiddenFromTrack: new Set(),
|
||||||
hideFinishedForParent(serverId, parentAgentId) {
|
hideFromTrack(serverId, parentAgentId, subagentIds) {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const prefix = parentPrefix(serverId, parentAgentId);
|
|
||||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
for (const [key, subagent] of state.descriptors) {
|
for (const subagentId of subagentIds) {
|
||||||
if (key.startsWith(prefix) && subagent.status !== "running") {
|
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
|
||||||
hiddenFromTrack.add(key);
|
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { hiddenFromTrack };
|
return { hiddenFromTrack };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("selectSubagentsForParent", () => {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
store.hideFinishedForParent(SERVER_ID, "parent-a");
|
store.hideFromTrack(SERVER_ID, "parent-a", ["provider-child"]);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
selectProviderSubagentsForParent(
|
selectProviderSubagentsForParent(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe("formatHeaderLabel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("countFinishedSubagents", () => {
|
describe("countFinishedSubagents", () => {
|
||||||
it("counts only terminal provider-owned children", () => {
|
it("counts eligible managed and terminal provider-owned children", () => {
|
||||||
const providerRows: SubagentRow[] = [
|
const providerRows: SubagentRow[] = [
|
||||||
{
|
{
|
||||||
kind: "provider",
|
kind: "provider",
|
||||||
@@ -110,6 +110,16 @@ describe("countFinishedSubagents", () => {
|
|||||||
row({ id: "managed-idle", status: "idle" }),
|
row({ id: "managed-idle", status: "idle" }),
|
||||||
...providerRows,
|
...providerRows,
|
||||||
]),
|
]),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes running and initializing managed children", () => {
|
||||||
|
expect(
|
||||||
|
countFinishedSubagents([
|
||||||
|
row({ id: "running", status: "running" }),
|
||||||
|
row({ id: "initializing", status: "initializing" }),
|
||||||
|
row({ id: "finished", status: "idle" }),
|
||||||
|
]),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
|
import { isFinishedSubagent } from "./archive-finished";
|
||||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
import { providerSubagentLifecycleStatus } from "./provider-store";
|
||||||
|
|
||||||
function presentationStatus(row: SubagentRow) {
|
function presentationStatus(row: SubagentRow) {
|
||||||
@@ -55,7 +56,7 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
|
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
|
||||||
return rows.filter((row) => row.kind === "provider" && row.status !== "running").length;
|
return rows.filter(isFinishedSubagent).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRowLabel(title: string | null | undefined): string | null {
|
export function resolveRowLabel(title: string | null | undefined): string | null {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-tab-presentation";
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
|
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||||
import {
|
import {
|
||||||
buildSubagentRowPresentationData,
|
buildSubagentRowPresentationData,
|
||||||
countFinishedSubagents,
|
countFinishedSubagents,
|
||||||
@@ -34,6 +35,7 @@ export interface SubagentsTrackProps {
|
|||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||||
onArchiveSubagent: (id: string) => void;
|
onArchiveSubagent: (id: string) => void;
|
||||||
onArchiveFinished?: () => void;
|
onArchiveFinished?: () => void;
|
||||||
|
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||||
onDetachSubagent?: (id: string) => void;
|
onDetachSubagent?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export function SubagentsTrack({
|
|||||||
onOpenProviderSubagent,
|
onOpenProviderSubagent,
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onArchiveFinished,
|
onArchiveFinished,
|
||||||
|
archiveFinishedStatus = { kind: "idle" },
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackProps): ReactElement | null {
|
}: SubagentsTrackProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -75,12 +78,15 @@ export function SubagentsTrack({
|
|||||||
);
|
);
|
||||||
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
||||||
|
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
||||||
|
if (rows.length === 0 && !isArchivingFinished && !isArchiveFinishedFailed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerLabel = formatHeaderLabel(rows);
|
const headerLabel = formatHeaderLabel(rows);
|
||||||
const finishedCount = countFinishedSubagents(rows);
|
const finishedCount = countFinishedSubagents(rows);
|
||||||
|
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outer} testID="subagents-track">
|
<View style={styles.outer} testID="subagents-track">
|
||||||
@@ -100,7 +106,7 @@ export function SubagentsTrack({
|
|||||||
>
|
>
|
||||||
{headerLabel}
|
{headerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{finishedCount > 0 && onArchiveFinished ? (
|
{showArchiveFinished && onArchiveFinished ? (
|
||||||
<View style={styles.headerAction}>
|
<View style={styles.headerAction}>
|
||||||
<SubagentActionButton
|
<SubagentActionButton
|
||||||
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
||||||
@@ -108,8 +114,28 @@ export function SubagentsTrack({
|
|||||||
tooltipLabel={t("subagents.archiveFinishedTooltip")}
|
tooltipLabel={t("subagents.archiveFinishedTooltip")}
|
||||||
icon="archive"
|
icon="archive"
|
||||||
visible
|
visible
|
||||||
|
disabled={isArchivingFinished}
|
||||||
onPress={onArchiveFinished}
|
onPress={onArchiveFinished}
|
||||||
/>
|
/>
|
||||||
|
{archiveFinishedStatus.kind === "archiving" ? (
|
||||||
|
<Text
|
||||||
|
style={styles.archiveFinishedStatus}
|
||||||
|
testID="subagents-track-archive-progress"
|
||||||
|
>
|
||||||
|
{archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{archiveFinishedStatus.kind === "failed" ? (
|
||||||
|
<Text
|
||||||
|
style={styles.archiveFinishedStatus}
|
||||||
|
testID="subagents-track-archive-failed"
|
||||||
|
>
|
||||||
|
{t("subagents.archiveFinishedRetry", {
|
||||||
|
failed: archiveFinishedStatus.failedCount,
|
||||||
|
total: archiveFinishedStatus.totalCount,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -275,6 +301,7 @@ function SubagentActionButton({
|
|||||||
tooltipLabel,
|
tooltipLabel,
|
||||||
icon,
|
icon,
|
||||||
visible,
|
visible,
|
||||||
|
disabled = false,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
@@ -282,15 +309,17 @@ function SubagentActionButton({
|
|||||||
tooltipLabel: string;
|
tooltipLabel: string;
|
||||||
icon: SubagentActionIcon;
|
icon: SubagentActionIcon;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild disabled={!visible}>
|
<TooltipTrigger asChild disabled={!visible || disabled}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
@@ -344,6 +373,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
headerAction: {
|
headerAction: {
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
archiveFinishedStatus: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
headerCollapsed: {
|
headerCollapsed: {
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
createArchiveFinishedSubagents,
|
||||||
|
type ArchiveFinishedOutcome,
|
||||||
|
type ArchiveFinishedState,
|
||||||
|
type ArchiveFinishedSubagents,
|
||||||
|
} from "./archive-finished";
|
||||||
|
import { useProviderSubagentStore } from "./provider-store";
|
||||||
|
import type { SubagentRow } from "./select";
|
||||||
|
|
||||||
|
export type { ArchiveFinishedStatus } from "./archive-finished";
|
||||||
|
|
||||||
|
export interface UseArchiveFinishedSubagentsInput {
|
||||||
|
serverId: string;
|
||||||
|
parentAgentId: string;
|
||||||
|
rows: readonly SubagentRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveFinishedSubagentsCapability extends ArchiveFinishedState {
|
||||||
|
archiveFinished: () => Promise<ArchiveFinishedOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveFinishedSubagents({
|
||||||
|
serverId,
|
||||||
|
parentAgentId,
|
||||||
|
rows,
|
||||||
|
}: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability {
|
||||||
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
const archiveFinished = useMemo<ArchiveFinishedSubagents>(
|
||||||
|
() =>
|
||||||
|
createArchiveFinishedSubagents([], {
|
||||||
|
parentAgentId,
|
||||||
|
getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id),
|
||||||
|
archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }),
|
||||||
|
dismissProviderSubagents: (ids) => {
|
||||||
|
useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[archiveAgent, parentAgentId, serverId],
|
||||||
|
);
|
||||||
|
const state = useSyncExternalStore(
|
||||||
|
archiveFinished.subscribe,
|
||||||
|
archiveFinished.getState,
|
||||||
|
archiveFinished.getState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
archiveFinished.setRows(rows);
|
||||||
|
}, [archiveFinished, rows]);
|
||||||
|
|
||||||
|
return { ...state, archiveFinished: archiveFinished.archiveFinished };
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useProviderSubagentStore } from "./provider-store";
|
|
||||||
|
|
||||||
export interface UseHideFinishedProviderSubagentsInput {
|
|
||||||
serverId: string;
|
|
||||||
parentAgentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHideFinishedProviderSubagents({
|
|
||||||
serverId,
|
|
||||||
parentAgentId,
|
|
||||||
}: UseHideFinishedProviderSubagentsInput): () => void {
|
|
||||||
return useCallback(() => {
|
|
||||||
useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId);
|
|
||||||
}, [parentAgentId, serverId]);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user