diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 01e392ffa..a6778364e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -89,7 +89,9 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. -Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. +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. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. @@ -109,7 +111,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. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. +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. ### Cross-client tab dismissal diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts index 62c08694e..a4bf02fad 100644 --- a/packages/app/e2e/provider-subagents.real.spec.ts +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents"; interface ProviderSubagentCase { provider: RewindFlowProvider; sentinel: string; + expectedName: string; prompt: string; providerConfig?: Parameters[0]["providerConfig"]; } @@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [ { provider: "claude", sentinel: "CLAUDE_CHILD_SENTINEL", + expectedName: "sentinel_child", providerConfig: { model: "opus" }, prompt: - "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + 'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "codex", sentinel: "CODEX_CHILD_SENTINEL", + expectedName: "Sentinel child", providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, prompt: - 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + 'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "opencode", sentinel: "OPENCODE_CHILD_SENTINEL", + expectedName: "Explore", prompt: "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", }, @@ -66,6 +70,7 @@ test.describe("real provider subagent timelines", () => { const rows = page.locator('[data-testid^="subagents-track-row-"]'); await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await expect(rows.first()).toContainText(scenario.expectedName); await rows.first().click(); const panel = page.getByTestId("provider-subagent-panel"); @@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => { await expect( panel.getByText("Start chatting with this agent...", { exact: true }), ).toHaveCount(0); + + await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), + ).toBeVisible({ timeout: 60_000 }); + const archiveFinished = page.getByTestId("subagents-track-archive-finished"); + await expect(archiveFinished).toBeVisible({ timeout: 30_000 }); + await archiveFinished.click(); + await expect(rows).toHaveCount(0, { timeout: 30_000 }); } finally { await cleanupRewindFlow({ handle, cwd }); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 52920c2bc..b5864d682 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1370,6 +1370,8 @@ export const ar: TranslationResources = { detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", + archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", + archiveFinishedTooltip: "أرشفة المكتملين", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cfe4612cb..213961f7c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1378,6 +1378,8 @@ export const en = { detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", + archiveFinishedAction: "Archive finished subagents", + archiveFinishedTooltip: "Archive finished", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1172b96ca..e8ecb927f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1409,6 +1409,8 @@ export const es: TranslationResources = { detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", + archiveFinishedAction: "Archivar subagentes finalizados", + archiveFinishedTooltip: "Archivar finalizados", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9a8b32c4e..81cf5fddd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1412,6 +1412,8 @@ export const fr: TranslationResources = { detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", + archiveFinishedAction: "Archiver les sous-agents terminés", + archiveFinishedTooltip: "Archiver les terminés", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 035970583..849aa62bd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1387,6 +1387,8 @@ export const ja: TranslationResources = { detachTooltip: "サブエージェントを切り離す", archiveAction: "{{label}}をアーカイブ", archiveTooltip: "サブエージェントをアーカイブ", + archiveFinishedAction: "完了したサブエージェントをアーカイブ", + archiveFinishedTooltip: "完了した項目をアーカイブ", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8966fe0cc..f4d6a4d5d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1395,6 +1395,8 @@ export const ptBR: TranslationResources = { detachTooltip: "Desanexar subagente", archiveAction: "Arquivar {{label}}", archiveTooltip: "Arquivar subagente", + archiveFinishedAction: "Arquivar subagentes concluídos", + archiveFinishedTooltip: "Arquivar concluídos", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index bd1ea0172..9755b2425 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1401,6 +1401,8 @@ export const ru: TranslationResources = { detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", + archiveFinishedAction: "Архивировать завершенные субагенты", + archiveFinishedTooltip: "Архивировать завершенные", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 067b10304..fce6d1266 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = { detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", + archiveFinishedAction: "归档已完成的 subagent", + archiveFinishedTooltip: "归档已完成项", }, panels: { draft: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 73bf635f9..ba7b899a7 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -68,7 +68,12 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents"; +import { + useHideFinishedProviderSubagents, + useArchiveSubagent, + useDetachSubagent, + useSubagentsForParent, +} from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -1390,6 +1395,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); + const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ + serverId, + parentAgentId: agentId, + }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1490,6 +1499,7 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} + onArchiveFinished={handleHideFinishedProviderSubagents} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> { - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("provider subagent client store", () => { @@ -129,6 +133,123 @@ describe("provider subagent client store", () => { ).toBe(false); }); + test("hides finished children locally without removing their timelines", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Finished output." }, + }); + + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.get(key)?.title).toBe("Finished child"); + expect(state.hiddenFromTrack.has(key)).toBe(true); + expect(state.timelines.get(key)?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Finished output." }), + ]); + }); + + test("reveals a hidden child when the provider reports it running again", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.replaceList(SERVER_ID, PARENT_ID, [completed]); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { ...completed, status: "running", updatedAt: "2026-07-12T10:01:00.000Z" }, + }); + + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(false); + }); + + test("keeps hidden state when a child temporarily disappears from the provider list", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + store.replaceList(SERVER_ID, PARENT_ID, []); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.has(key)).toBe(false); + expect(state.hiddenFromTrack.has(key)).toBe(true); + }); + + test("keeps a finished child hidden across remove and history replay", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.applyUpdate(SERVER_ID, { + kind: "remove", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + }); + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + }); test("applies terminal list status to a timeline received before its descriptor", () => { const store = useProviderSubagentStore.getState(); store.applyUpdate(SERVER_ID, { diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index f0d76ee70..6bd8b90f2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState { interface ProviderSubagentState { descriptors: Map; timelines: Map; + hiddenFromTrack: Set; + hideFinishedForParent(serverId: string, parentAgentId: string): void; replaceList( serverId: string, parentAgentId: string, @@ -193,14 +195,32 @@ function buildTimelineResponseRows( export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), + hiddenFromTrack: new Set(), + hideFinishedForParent(serverId, parentAgentId) { + 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); + } + } + return { hiddenFromTrack }; + }); + }, replaceList(serverId, parentAgentId, subagents) { set((state) => { const prefix = parentPrefix(serverId, parentAgentId); const descriptors = new Map( [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), ); + const hiddenFromTrack = new Set(state.hiddenFromTrack); for (const subagent of subagents) { - descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent); + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + descriptors.set(key, subagent); + if (subagent.status === "running") { + hiddenFromTrack.delete(key); + } } const retainedKeys = new Set(descriptors.keys()); const timelines = new Map( @@ -217,7 +237,7 @@ export const useProviderSubagentStore = create((set) => ( ); } } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; }); }, applyUpdate(serverId, payload) { @@ -229,8 +249,12 @@ export const useProviderSubagentStore = create((set) => ( payload.subagent.id, ); const descriptors = new Map(state.descriptors); + const hiddenFromTrack = new Set(state.hiddenFromTrack); const previous = descriptors.get(key); descriptors.set(key, payload.subagent); + if (payload.subagent.status === "running") { + hiddenFromTrack.delete(key); + } let timelines = state.timelines; const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { @@ -240,13 +264,13 @@ export const useProviderSubagentStore = create((set) => ( buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), ); } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; } if (payload.kind === "remove") { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const descriptors = new Map(state.descriptors); - const timelines = new Map(state.timelines); descriptors.delete(key); + const timelines = new Map(state.timelines); timelines.delete(key); return { descriptors, timelines }; } diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 4a8599467..7545ea27c 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void { afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("selectSubagentsForParent", () => { @@ -90,6 +94,34 @@ describe("selectSubagentsForParent", () => { ).toEqual(["provider-child"]); }); + it("hides locally dismissed provider children while retaining their descriptor", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: "provider-child", + parentAgentId: "parent-a", + provider: "codex", + title: "Provider child", + description: null, + status: "completed", + createdAt: "2026-03-08T10:01:00.000Z", + updatedAt: "2026-03-08T10:02:00.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, "parent-a"); + + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + true, + ), + ).toEqual([]); + expect(useProviderSubagentStore.getState().descriptors.size).toBe(1); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index a235cc445..d96c1628a 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent( const rows: ProviderSubagentRow[] = []; const prefix = `${params.serverId}\0${params.parentAgentId}\0`; for (const [key, subagent] of state.descriptors) { - if (!key.startsWith(prefix)) continue; + if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue; rows.push({ kind: "provider", id: subagent.id, diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 2802c1707..5f74fd6bd 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { PaseoSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, + countFinishedSubagents, formatHeaderLabel, resolveRowLabel, } from "./track-presentation"; @@ -72,6 +73,41 @@ describe("formatHeaderLabel", () => { }); }); +describe("countFinishedSubagents", () => { + it("counts only terminal provider-owned children", () => { + const providerRows: SubagentRow[] = [ + { + kind: "provider", + id: "native-running", + parentAgentId: "parent", + provider: "claude", + title: "running", + status: "running", + requiresAttention: false, + createdAt: new Date("2026-04-20T00:00:00.000Z"), + }, + { + kind: "provider", + id: "native-failed", + parentAgentId: "parent", + provider: "claude", + title: "failed", + status: "failed", + requiresAttention: true, + createdAt: new Date("2026-04-20T00:00:01.000Z"), + }, + ]; + + expect( + countFinishedSubagents([ + row({ id: "managed-running", status: "running" }), + row({ id: "managed-idle", status: "idle" }), + ...providerRows, + ]), + ).toBe(1); + }); +}); + describe("resolveRowLabel", () => { it("returns null when title is not a string", () => { expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d7255cc77..c4baa42f8 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -48,6 +48,10 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { return parts.join(" · "); } +export function countFinishedSubagents(rows: readonly SubagentRow[]): number { + return rows.filter((row) => row.kind === "provider" && row.status !== "running").length; +} + export function resolveRowLabel(title: SubagentRow["title"]): string | null { if (typeof title !== "string") { return null; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c0ff19ec3..9dd38c19b 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,7 +13,11 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; -import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; +import { + buildSubagentRowPresentationData, + countFinishedSubagents, + formatHeaderLabel, +} from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -30,6 +34,7 @@ export interface SubagentsTrackProps { onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; + onArchiveFinished?: () => void; onDetachSubagent?: (id: string) => void; } @@ -47,8 +52,10 @@ export function SubagentsTrack({ onOpenSubagent, onOpenProviderSubagent, onArchiveSubagent, + onArchiveFinished, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const toggleExpanded = useCallback(() => { @@ -62,10 +69,13 @@ export function SubagentsTrack({ const headerStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.header, - expanded ? styles.headerDivider : styles.headerCollapsed, + styles.headerToggle, (hovered || pressed) && styles.headerActive, ], + [], + ); + const headerContainerStyle = useMemo( + () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], [expanded], ); @@ -74,27 +84,42 @@ export function SubagentsTrack({ } const headerLabel = formatHeaderLabel(rows); + const finishedCount = countFinishedSubagents(rows); return ( - - {expanded ? ( - - ) : ( - - )} - - {headerLabel} - - + + + {expanded ? ( + + ) : ( + + )} + + {headerLabel} + + + {finishedCount > 0 && onArchiveFinished ? ( + + + + ) : null} + {expanded ? ( ({ header: { flexDirection: "row", alignItems: "center", + }, + headerToggle: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[1], paddingVertical: theme.spacing[2], }, + headerAction: { + paddingRight: theme.spacing[2], + }, headerCollapsed: { - paddingBottom: theme.spacing[6], + paddingBottom: theme.spacing[4], }, headerActive: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts new file mode 100644 index 000000000..7058fc8f1 --- /dev/null +++ b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts @@ -0,0 +1,16 @@ +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]); +} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 60001add3..99a6acfe2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] { } interface ClaudeHistoricalSubagentToolCall { + name?: string; subagentType?: string; description?: string; } @@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls( continue; } const input = toObjectRecord(block.input); + const name = readNonEmptyString(input?.name); const subagentType = readNonEmptyString(input?.subagent_type); const description = readNonEmptyString(input?.description); toolCalls.set(block.id, { + ...(name ? { name } : {}), ...(subagentType ? { subagentType } : {}), ...(description ? { description } : {}), }); @@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents( return events; } +function resolveClaudeHistoricalSubagentTitle( + toolCall: ClaudeHistoricalSubagentToolCall | undefined, +): string { + return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent"; +} + function buildClaudePersistedSidechainAgentEvents( agentId: string, entries: ClaudeHistoryEntry[], @@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents( event: { type: "upsert", id, - title: toolCall?.subagentType ?? "Claude subagent", + title: resolveClaudeHistoricalSubagentTitle(toolCall), description: toolCall?.description ?? null, status: "running", toolCallId: result?.toolCallId ?? null, diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index a9d852a45..6dab8d205 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => { type: "tool_use", id: "history-task-call", name: "Agent", - input: { description: "Inspect persisted history" }, + input: { + name: "history_researcher", + subagent_type: "Explore", + description: "Inspect persisted history", + }, }, ], }, @@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => { timestamp: "2026-07-12T10:00:01.000Z", }, }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + title: "history_researcher", + status: "running", + }), + }); expect(historyEvents).toContainEqual({ type: "provider_subagent", provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts new file mode 100644 index 000000000..615ca81f3 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; + +describe("ClaudeSidechainTracker", () => { + it("uses Claude's native agent name for the provider subagent title", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ + name: "repo_researcher", + subagent_type: "Explore", + description: "Inspect the repository", + }), + }); + + const events = tracker.handleMessage( + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [] }, + } as unknown as SDKMessage, + "task-1", + ); + + expect(events[0]).toEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: "task-1", + title: "repo_researcher", + description: "Inspect the repository", + status: "running", + toolCallId: "task-1", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index aef9e91b6..d5257e3d1 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -22,6 +22,7 @@ interface SubAgentActionEntry { } interface SubAgentActivityState { + name?: string; subAgentType?: string; description?: string; actions: SubAgentActionEntry[]; @@ -130,7 +131,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id: parentToolUseId, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status: "running", toolCallId: parentToolUseId, @@ -163,7 +164,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -185,7 +186,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -267,10 +268,15 @@ export class ClaudeSidechainTracker { parentToolUseId: string, ): boolean { const taskInput = this.getToolInput(parentToolUseId); + const nextName = this.normalizeSubAgentText(taskInput?.name); const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); const nextDescription = this.normalizeSubAgentText(taskInput?.description); let changed = false; + if (nextName && nextName !== state.name) { + state.name = nextName; + changed = true; + } if (nextSubAgentType && nextSubAgentType !== state.subAgentType) { state.subAgentType = nextSubAgentType; changed = true; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 228f67124..569370cae 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1847,6 +1847,60 @@ describe("Codex app-server provider", () => { }); }); + test("updates a registered child with its later native activity name", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-native-name-later", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the repository.", + receiverThreadIds: ["child-native-name-later"], + agentsStates: { + "child-native-name-later": { status: "pendingInit", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/started", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "activity-native-name-later", + kind: "started", + agentThreadId: "child-native-name-later", + agentPath: "/root/research/investigator", + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "child-native-name-later", + title: "Research / Investigator", + }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-native-name-later", + detail: { + type: "sub_agent", + subAgentType: "Research / Investigator", + description: "Inspect the repository.", + }, + }, + }); + }); + test("renders child MCP image results in the provider subagent timeline", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -2500,7 +2554,7 @@ describe("Codex app-server provider", () => { status: "running", detail: { type: "sub_agent", - description: "/root/legacy-only-child", + description: "legacy-only-child", }, }, }); @@ -2879,6 +2933,13 @@ describe("Codex app-server provider", () => { receiverThreadIds: ["legacy-child-thread"], agentsStates: { "legacy-child-thread": { status: "completed" } }, }, + { + type: "subAgentActivity", + id: "legacy-native-name-history", + kind: "started", + agentThreadId: "legacy-child-thread", + agentPath: "/root/sentinel_child", + }, { type: "subAgentActivity", id: "v2-spawn-history", @@ -2905,7 +2966,12 @@ describe("Codex app-server provider", () => { event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], ), ).toMatchObject([ - { type: "upsert", id: "legacy-child-thread", status: "completed" }, + { + type: "upsert", + id: "legacy-child-thread", + status: "completed", + title: "Sentinel child", + }, { type: "upsert", id: "v2-child-thread", status: "completed" }, ]); expect( @@ -2940,12 +3006,16 @@ describe("Codex app-server provider", () => { { callId: "legacy-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "Legacy child" }, + detail: { + type: "sub_agent", + description: "Legacy child", + subAgentType: "Sentinel child", + }, }, { callId: "v2-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "/root/v2-child" }, + detail: { type: "sub_agent", description: "v2-child" }, }, ]); @@ -3067,7 +3137,7 @@ describe("Codex app-server provider", () => { status: "canceled", detail: expect.objectContaining({ type: "sub_agent", - description: "/root/history-child", + description: "history-child", }), }), }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 48aeddb90..fd6b7b68e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1668,14 +1668,22 @@ function updateHistoricalSubAgentActivity( timeline: PersistedTimelineEntry[], index: number, kind: CodexSubAgentActivity["kind"], + subAgentType?: string, ): void { const existing = timeline[index]; if (existing?.item.type !== "tool_call") { return; } + const settledItem = settleHistoricalSubAgentActivity(existing.item, kind); timeline[index] = { ...existing, - item: settleHistoricalSubAgentActivity(existing.item, kind), + item: + subAgentType && settledItem.detail.type === "sub_agent" + ? { + ...settledItem, + detail: { ...settledItem.detail, subAgentType }, + } + : settledItem, }; } @@ -1869,10 +1877,15 @@ async function loadCodexThreadHistoryTimeline(params: { historicalSubAgentActivity.agentThreadId, ); if (existingIndex !== undefined) { + const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); updateHistoricalSubAgentActivity( timeline, existingIndex, historicalSubAgentActivity.kind, + activityTimelineItem?.type === "tool_call" && + activityTimelineItem.detail.type === "sub_agent" + ? activityTimelineItem.detail.subAgentType + : undefined, ); continue; } @@ -4843,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession { if (activity.id) { state.activityItemIds.add(activity.id); } + const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { + cwd: this.config.cwd ?? null, + }); + if ( + activityToolCall?.detail.type === "sub_agent" && + state.toolCall.detail.type === "sub_agent" + ) { + state.toolCall = { + ...state.toolCall, + detail: { + ...state.toolCall.detail, + subAgentType: activityToolCall.detail.subAgentType, + }, + }; + } this.emitSubAgentActivityUpdate( callId, activity.kind === "interrupted" ? "canceled" : "running", diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6c8919e52..f9df7ee62 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => { id: `activity-${kind}`, kind, agentThreadId: "child-thread-1", - agentPath: "/root/investigator", + agentPath: "/root/research/investigator", }); expect(item).toEqual({ @@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => { error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: "/root/investigator", + subAgentType: "Research / Investigator", + description: "research/investigator", log: "", actions: [], }, @@ -267,6 +267,60 @@ describe("codex tool-call mapper", () => { }); }); + it("humanizes a subagent task name for display", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-human-name", + kind: "started", + agentThreadId: "child-thread-human-name", + agentPath: "/root/hello_one", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Hello one", + description: "hello_one", + }, + }); + }); + + it("uses only the final segment of a subAgentActivity path outside the root namespace", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-external-path", + kind: "started", + agentThreadId: "child-thread-external-path", + agentPath: "/tmp/native/investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + + it("uses only the final segment of a Windows subAgentActivity path", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-windows-path", + kind: "started", + agentThreadId: "child-thread-windows-path", + agentPath: "C:\\Users\\dev\\agents\\investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + it("does not fail a collabAgentToolCall from child error state alone", () => { const item = mapCodexToolCallFromThreadItem({ type: "collabAgentToolCall", diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 967b6b43a..b3775e42c 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem( function mapSubAgentActivityItem( item: z.infer, ): ToolCallTimelineItem { + let nativeName = item.agentPath; + if (nativeName === "/root") { + nativeName = ""; + } else if (nativeName.startsWith("/root/")) { + nativeName = nativeName.slice("/root/".length); + } else if (/[\\/]/.test(nativeName)) { + nativeName = nativeName.slice( + Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1, + ); + } + const description = nativeName; + nativeName = nativeName + .split("/") + .map((segment) => segment.replace(/[_-]+/g, " ").trim()) + .filter(Boolean) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" / "); + nativeName ||= "Sub-agent"; return { type: "tool_call", callId: item.id, @@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem( error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: item.agentPath, + subAgentType: nativeName, + description, log: "", actions: [], },