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
This commit is contained in:
Mohamed Boudra
2026-07-15 11:58:31 +02:00
committed by GitHub
parent 38e4d9ad5d
commit abfe955867
28 changed files with 600 additions and 49 deletions
+2
View File
@@ -1370,6 +1370,8 @@ export const ar: TranslationResources = {
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
},
panels: {
draft: {
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -1387,6 +1387,8 @@ export const ja: TranslationResources = {
detachTooltip: "サブエージェントを切り離す",
archiveAction: "{{label}}をアーカイブ",
archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
},
panels: {
draft: {
+2
View File
@@ -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: {
+2
View File
@@ -1401,6 +1401,8 @@ export const ru: TranslationResources = {
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
},
panels: {
draft: {
+2
View File
@@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = {
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}",
archiveTooltip: "归档 subagent",
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
},
panels: {
draft: {
+11 -1
View File
@@ -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}
/>
<Composer
+4
View File
@@ -2,5 +2,9 @@ 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";
@@ -6,7 +6,11 @@ const PARENT_ID = "parent-1";
const SUBAGENT_ID = "child-1";
afterEach(() => {
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, {
+28 -4
View File
@@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState {
interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
hiddenFromTrack: Set<string>;
hideFinishedForParent(serverId: string, parentAgentId: string): void;
replaceList(
serverId: string,
parentAgentId: string,
@@ -193,14 +195,32 @@ function buildTimelineResponseRows(
export const useProviderSubagentStore = create<ProviderSubagentState>((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<ProviderSubagentState>((set) => (
);
}
}
return { descriptors, timelines };
return { descriptors, timelines, hiddenFromTrack };
});
},
applyUpdate(serverId, payload) {
@@ -229,8 +249,12 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((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<ProviderSubagentState>((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 };
}
+33 -1
View File
@@ -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" }),
+1 -1
View File
@@ -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,
@@ -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);
@@ -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;
+56 -21
View File
@@ -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 (
<View style={styles.outer} testID="subagents-track">
<View style={styles.track}>
<View style={surfaceStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
<View style={headerContainerStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{finishedCount > 0 && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
tooltipLabel={t("subagents.archiveFinishedTooltip")}
icon="archive"
visible
onPress={onArchiveFinished}
/>
</View>
) : null}
</View>
{expanded ? (
<ScrollView
style={styles.scroll}
@@ -306,12 +331,22 @@ const styles = StyleSheet.create((theme) => ({
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,
@@ -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]);
}