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:
Mohamed Boudra
2026-08-14 18:16:22 +02:00
committed by GitHub
parent 23853a1515
commit 75bbca4a1b
24 changed files with 998 additions and 42 deletions
+2 -2
View File
@@ -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 });
+1
View File
@@ -1618,6 +1618,7 @@ export const ar: TranslationResources = {
archiveTooltip: "أرشفة الوكيل الفرعي", archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين", archiveFinishedTooltip: "أرشفة المكتملين",
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
}, },
panels: { panels: {
draft: { draft: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -1634,6 +1634,7 @@ export const ja: TranslationResources = {
archiveTooltip: "サブエージェントをアーカイブ", archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ", archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ", archiveFinishedTooltip: "完了した項目をアーカイブ",
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
}, },
panels: { panels: {
draft: { draft: {
+1
View File
@@ -1628,6 +1628,7 @@ export const ko: TranslationResources = {
archiveTooltip: "서브에이전트 보관", archiveTooltip: "서브에이전트 보관",
archiveFinishedAction: "완료된 하위 에이전트 보관", archiveFinishedAction: "완료된 하위 에이전트 보관",
archiveFinishedTooltip: "아카이브 완료", archiveFinishedTooltip: "아카이브 완료",
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
}, },
panels: { panels: {
draft: { draft: {
+1
View File
@@ -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: {
+1
View File
@@ -1652,6 +1652,7 @@ export const ru: TranslationResources = {
archiveTooltip: "Архивный субагент", archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты", archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные", archiveFinishedTooltip: "Архивировать завершенные",
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
}, },
panels: { panels: {
draft: { draft: {
+1
View File
@@ -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: {
+5 -3
View File
@@ -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 };
}
+5 -4
View File
@@ -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,
+5 -7
View File
@@ -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 };
}); });
+1 -1
View File
@@ -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 {
+39 -3
View File
@@ -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]);
}