From 75bbca4a1bf0976cde868d653f66369ecd958538 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 18:16:22 +0200 Subject: [PATCH] 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 --- docs/agent-lifecycle.md | 4 +- .../archive-finished-subagents.spec.ts | 79 ++++ packages/app/e2e/support/helpers/subagents.ts | 153 +++++++ packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/panels/agent-panel.tsx | 8 +- .../src/subagents/archive-finished.test.ts | 426 ++++++++++++++++++ .../app/src/subagents/archive-finished.ts | 203 +++++++++ packages/app/src/subagents/index.ts | 9 +- .../app/src/subagents/provider-store.test.ts | 8 +- packages/app/src/subagents/provider-store.ts | 12 +- packages/app/src/subagents/select.test.ts | 2 +- .../src/subagents/track-presentation.test.ts | 12 +- .../app/src/subagents/track-presentation.ts | 3 +- packages/app/src/subagents/track.tsx | 42 +- .../app/src/subagents/use-archive-finished.ts | 54 +++ .../use-hide-finished-provider-subagents.ts | 16 - 24 files changed, 998 insertions(+), 42 deletions(-) create mode 100644 packages/app/e2e/browser/archive-finished-subagents.spec.ts create mode 100644 packages/app/src/subagents/archive-finished.test.ts create mode 100644 packages/app/src/subagents/archive-finished.ts create mode 100644 packages/app/src/subagents/use-archive-finished.ts delete mode 100644 packages/app/src/subagents/use-hide-finished-provider-subagents.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 4da30bf0e..a607873ad 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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. -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. @@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### 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 diff --git a/packages/app/e2e/browser/archive-finished-subagents.spec.ts b/packages/app/e2e/browser/archive-finished-subagents.spec.ts new file mode 100644 index 000000000..9f7b42a4a --- /dev/null +++ b/packages/app/e2e/browser/archive-finished-subagents.spec.ts @@ -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); + }); +}); diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index b70797329..68fd5f732 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -1,7 +1,19 @@ import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { expect, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; import type { SeededWorkspace } from "./seed-client"; +type WebSocketMessage = string | Buffer; + +export interface ManagedSubagentArchiveGate { + release(): void; + waitForRequest(): Promise; +} + +export interface ManagedSubagentArchiveRejection { + waitForRejection(): Promise; +} + export interface SeededSubagentPair { parent: { id: string; @@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis }); } +export async function expectManagedSubagentUnarchived( + workspace: Pick, + childId: string, +): Promise { + await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({ + agent: { id: childId, archivedAt: null }, + }); +} + +export async function expectManagedSubagentArchived( + workspace: Pick, + childId: string, +): Promise { + 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 { + await page.getByRole("button", { name: "Archive finished subagents" }).click(); +} + +export async function expectArchiveFinishedInProgress( + page: Page, + completed: number, + total: number, +): Promise { + 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 { + 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 { + let released = false; + let resolveRequest: (() => void) | undefined; + const delayedForwards: Array<() => void> = []; + const request = new Promise((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 { + let resolveRejection: (() => void) | undefined; + const rejection = new Promise((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 { const row = page.getByTestId(`subagents-track-row-${childId}`); await expect(row).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 039ed1c4d..923642968 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1618,6 +1618,7 @@ export const ar: TranslationResources = { archiveTooltip: "أرشفة الوكيل الفرعي", archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", archiveFinishedTooltip: "أرشفة المكتملين", + archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 733929c11..c12ab440d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1628,6 +1628,7 @@ export const en = { archiveTooltip: "Archive subagent", archiveFinishedAction: "Archive finished subagents", archiveFinishedTooltip: "Archive finished", + archiveFinishedRetry: "Retry ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d6f8b9efb..f28516005 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1661,6 +1661,7 @@ export const es: TranslationResources = { archiveTooltip: "Subagente de archivo", archiveFinishedAction: "Archivar subagentes finalizados", archiveFinishedTooltip: "Archivar finalizados", + archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a2075164c..0bda488b2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1665,6 +1665,7 @@ export const fr: TranslationResources = { archiveTooltip: "Sous-agent d'archivage", archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedTooltip: "Archiver les terminés", + archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f22347533..c20a905be 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1634,6 +1634,7 @@ export const ja: TranslationResources = { archiveTooltip: "サブエージェントをアーカイブ", archiveFinishedAction: "完了したサブエージェントをアーカイブ", archiveFinishedTooltip: "完了した項目をアーカイブ", + archiveFinishedRetry: "再試行 ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7d3d8f507..99f427833 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1628,6 +1628,7 @@ export const ko: TranslationResources = { archiveTooltip: "서브에이전트 보관", archiveFinishedAction: "완료된 하위 에이전트 보관", archiveFinishedTooltip: "아카이브 완료", + archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ea2404a05..f0a09e4d8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1647,6 +1647,7 @@ export const ptBR: TranslationResources = { archiveTooltip: "Arquivar subagente", archiveFinishedAction: "Arquivar subagentes concluídos", archiveFinishedTooltip: "Arquivar concluídos", + archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 189875688..430a1448c 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1652,6 +1652,7 @@ export const ru: TranslationResources = { archiveTooltip: "Архивный субагент", archiveFinishedAction: "Архивировать завершенные субагенты", archiveFinishedTooltip: "Архивировать завершенные", + archiveFinishedRetry: "Повторить ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b93bb98cb..333fd8a0c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1598,6 +1598,7 @@ export const zhCN: TranslationResources = { archiveTooltip: "归档 subagent", archiveFinishedAction: "归档已完成的 subagent", archiveFinishedTooltip: "归档已完成项", + archiveFinishedRetry: "重试 ({{failed}}/{{total}})", }, panels: { draft: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 59eb41112..e2cea4e57 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; import { - useHideFinishedProviderSubagents, + useArchiveFinishedSubagents, useArchiveSubagent, useDetachSubagent, useSubagentsForParent, @@ -1532,9 +1532,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); - const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ + const archiveFinishedSubagents = useArchiveFinishedSubagents({ serverId, parentAgentId: agentId, + rows: subagentRows, }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, @@ -1628,7 +1629,8 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} - onArchiveFinished={handleHideFinishedProviderSubagents} + onArchiveFinished={archiveFinishedSubagents.archiveFinished} + archiveFinishedStatus={archiveFinishedSubagents.status} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> = {}, +): ManagedSubagentSnapshot { + return { + id, + status, + parentAgentId: "parent", + archivedAt: null, + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + const second = deferred(); + 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(); + const second = deferred(); + const retry = deferred(); + 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) => current.delete("failed")], + [ + "is archived", + (current: Map) => + current.set("failed", managed("failed", "idle", { archivedAt: new Date() })), + ], + [ + "belongs to a different parent", + (current: Map) => + 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([ + ["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")); + }); +}); diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts new file mode 100644 index 000000000..0f3c68559 --- /dev/null +++ b/packages/app/src/subagents/archive-finished.ts @@ -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; + +export interface ArchiveFinishedSubagentsDeps { + parentAgentId: string; + getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined; + archiveManagedSubagent: (id: string) => Promise; + dismissProviderSubagents: (ids: string[]) => void; +} + +export interface ArchiveFinishedSubagents { + getState(): ArchiveFinishedState; + setRows(rows: readonly SubagentRow[]): void; + subscribe(listener: () => void): () => void; + archiveFinished(): Promise; +} + +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 { + return new Set(rows.map(rowIdentity)); +} + +function identitySignature(ids: ReadonlySet): 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 }> { + 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(); + + 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 }; +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 959e54b69..e93d62eb2 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -2,9 +2,10 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-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 { isWorkspaceRootAgent } from "./workspace-root-policy"; +export { + useArchiveFinishedSubagents, + type ArchiveFinishedStatus, + type UseArchiveFinishedSubagentsInput, +} from "./use-archive-finished"; diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index d14edbd9b..ea863ea37 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -160,7 +160,7 @@ describe("provider subagent client store", () => { 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 key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); @@ -185,7 +185,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }; 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]); const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); @@ -215,7 +215,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }, }); - store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); store.replaceList(SERVER_ID, PARENT_ID, []); @@ -239,7 +239,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }; 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, { kind: "remove", parentAgentId: PARENT_ID, diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 6bd8b90f2..15a9eb9b7 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -33,7 +33,7 @@ interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; - hideFinishedForParent(serverId: string, parentAgentId: string): void; + hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, parentAgentId: string, @@ -196,14 +196,12 @@ export const useProviderSubagentStore = create((set) => ( descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), - hideFinishedForParent(serverId, parentAgentId) { + hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { - const prefix = parentPrefix(serverId, parentAgentId); const hiddenFromTrack = new Set(state.hiddenFromTrack); - for (const [key, subagent] of state.descriptors) { - if (key.startsWith(prefix) && subagent.status !== "running") { - hiddenFromTrack.add(key); - } + for (const subagentId of subagentIds) { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key); } return { hiddenFromTrack }; }); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 6d5b38e65..5229acf88 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -116,7 +116,7 @@ describe("selectSubagentsForParent", () => { toolCallId: "call-1", }, }); - store.hideFinishedForParent(SERVER_ID, "parent-a"); + store.hideFromTrack(SERVER_ID, "parent-a", ["provider-child"]); expect( selectProviderSubagentsForParent( diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 0dfd1b3cf..75574a182 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -76,7 +76,7 @@ describe("formatHeaderLabel", () => { }); describe("countFinishedSubagents", () => { - it("counts only terminal provider-owned children", () => { + it("counts eligible managed and terminal provider-owned children", () => { const providerRows: SubagentRow[] = [ { kind: "provider", @@ -110,6 +110,16 @@ describe("countFinishedSubagents", () => { row({ id: "managed-idle", status: "idle" }), ...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); }); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index b1a0fc3b7..a78c1ecdf 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,6 +1,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { @@ -55,7 +56,7 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { } 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 { diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 8878851da..33f9efe57 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -14,6 +14,7 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; +import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { buildSubagentRowPresentationData, countFinishedSubagents, @@ -34,6 +35,7 @@ export interface SubagentsTrackProps { onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; + archiveFinishedStatus?: ArchiveFinishedStatus; onDetachSubagent?: (id: string) => void; } @@ -55,6 +57,7 @@ export function SubagentsTrack({ onOpenProviderSubagent, onArchiveSubagent, onArchiveFinished, + archiveFinishedStatus = { kind: "idle" }, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { const { t } = useTranslation(); @@ -75,12 +78,15 @@ export function SubagentsTrack({ ); 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; } const headerLabel = formatHeaderLabel(rows); const finishedCount = countFinishedSubagents(rows); + const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed; return ( @@ -100,7 +106,7 @@ export function SubagentsTrack({ > {headerLabel} - {finishedCount > 0 && onArchiveFinished ? ( + {showArchiveFinished && onArchiveFinished ? ( + {archiveFinishedStatus.kind === "archiving" ? ( + + {archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount} + + ) : null} + {archiveFinishedStatus.kind === "failed" ? ( + + {t("subagents.archiveFinishedRetry", { + failed: archiveFinishedStatus.failedCount, + total: archiveFinishedStatus.totalCount, + })} + + ) : null} ) : null} @@ -275,6 +301,7 @@ function SubagentActionButton({ tooltipLabel, icon, visible, + disabled = false, onPress, }: { accessibilityLabel: string; @@ -282,15 +309,17 @@ function SubagentActionButton({ tooltipLabel: string; icon: SubagentActionIcon; visible: boolean; + disabled?: boolean; onPress: () => void; }): ReactElement { return ( - + ({ }, headerAction: { paddingRight: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + archiveFinishedStatus: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, }, headerCollapsed: { paddingBottom: theme.spacing[4], diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts new file mode 100644 index 000000000..e7653f054 --- /dev/null +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -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; +} + +export function useArchiveFinishedSubagents({ + serverId, + parentAgentId, + rows, +}: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability { + const { archiveAgent } = useArchiveAgent(); + const archiveFinished = useMemo( + () => + 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 }; +} diff --git a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts deleted file mode 100644 index 7058fc8f1..000000000 --- a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts +++ /dev/null @@ -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]); -}