From abfe955867f036e6e32d67cd9ab2d06c43c9e1b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 11:58:31 +0200 Subject: [PATCH] Show friendly native subagent names and hide finished work (#2073) * feat(subagents): archive finished children and show native names Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them. * fix(subagents): cover persistence and naming edge cases * fix(subagents): preserve active descendants and native names * fix(subagents): retain dismissed timelines and trim paths * fix(subagents): retain history across hydration * test(subagents): expect normalized native descriptions * fix(subagents): separate hidden rows from retained history * fix(subagents): preserve restored hidden tabs safely * fix(subagents): integrate archive with run state * test(codex): expect normalized child name * fix(subagents): retain dismissed tabs on reload * fix(subagents): hide finished native agents locally Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol. * fix(subagents): humanize native agent names * fix(subagents): preserve names in Codex history * fix(subagents): retain hidden rows across reloads --- docs/agent-lifecycle.md | 6 +- .../app/e2e/provider-subagents.real.spec.ts | 18 ++- packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/app/src/panels/agent-panel.tsx | 12 +- packages/app/src/subagents/index.ts | 4 + .../app/src/subagents/provider-store.test.ts | 123 +++++++++++++++++- packages/app/src/subagents/provider-store.ts | 32 ++++- packages/app/src/subagents/select.test.ts | 34 ++++- packages/app/src/subagents/select.ts | 2 +- .../src/subagents/track-presentation.test.ts | 36 +++++ .../app/src/subagents/track-presentation.ts | 4 + packages/app/src/subagents/track.tsx | 77 ++++++++--- .../use-hide-finished-provider-subagents.ts | 16 +++ .../server/agent/providers/claude/agent.ts | 11 +- .../agent.voice-history-regression.test.ts | 16 ++- .../claude/sidechain-tracker.test.ts | 38 ++++++ .../providers/claude/sidechain-tracker.ts | 12 +- .../providers/codex-app-server-agent.test.ts | 80 +++++++++++- .../agent/providers/codex-app-server-agent.ts | 30 ++++- .../providers/codex/tool-call-mapper.test.ts | 60 ++++++++- .../agent/providers/codex/tool-call-mapper.ts | 22 +++- 28 files changed, 600 insertions(+), 49 deletions(-) create mode 100644 packages/app/src/subagents/use-hide-finished-provider-subagents.ts create mode 100644 packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts 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: [], },