diff --git a/packages/app/src/composer/task-list/index.tsx b/packages/app/src/composer/task-list/index.tsx index 8a3537f3b..0390f4e46 100644 --- a/packages/app/src/composer/task-list/index.tsx +++ b/packages/app/src/composer/task-list/index.tsx @@ -24,11 +24,12 @@ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] // Counts only. The active task used to ride along in the header, where it was the first thing // truncated on a phone; the panel shows it in full, in place, with the rest of the list. const label = t("message.todo.tasksProgress", { completed, total: tasks.length }); + const segments = useMemo(() => [{ bucket: null, text: label }], [label]); return ( {tasks.map((task, index) => ( diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 52833b9dc..0b74c7e1d 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -2,7 +2,9 @@ import React, { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { Pressable, Text } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ComposerTrackPill, ComposerTrackRow } from "./tracks"; +import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks"; + +const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }]; // App sources compile against the classic JSX runtime, which expects React on the global. beforeEach(() => vi.stubGlobal("React", React)); @@ -66,7 +68,7 @@ describe("composer track panel", () => { it("dismisses itself when a row is chosen", () => { const onPress = vi.fn(); mount( - + Subagent one @@ -87,7 +89,7 @@ describe("composer track panel", () => { it("stays open for a row whose result lands in the panel", () => { const onPress = vi.fn(); mount( - + { const onPress = vi.fn(); const onAction = vi.fn(); mount( - + Subagent one @@ -131,26 +133,27 @@ describe("composer track panel", () => { }); describe("composer track pill status mark", () => { - function mountMark(statusBucket: "running" | "failed"): HTMLElement { + /** The mark is the glyph itself: the dot for a still state, the ring's carrier for running. */ + function mountMark(bucket: "running" | "failed"): { mark: HTMLElement; body: HTMLElement } { const container = mount( rows , ); - const mark = container.querySelector('[data-testid="pill"] > div'); - if (!(mark instanceof HTMLElement)) { + const segment = container.querySelector('[data-testid="pill-segment-0"]'); + const body = container.querySelector('[data-testid="pill"]'); + if (!(segment?.firstElementChild instanceof HTMLElement) || !(body instanceof HTMLElement)) { throw new Error("pill did not render a status mark"); } - return mark; + return { mark: segment.firstElementChild, body }; } it("spins the shared ring while a child is running", () => { - const mark = mountMark("running"); + const { mark } = mountMark("running"); const animated = [...mark.querySelectorAll("*")].filter( (element) => element.getAnimations().length > 0, ); @@ -164,20 +167,53 @@ describe("composer track pill status mark", () => { }); it("draws a still dot for every other state", () => { - const mark = mountMark("failed"); + const { mark } = mountMark("failed"); const animated = [...mark.querySelectorAll("*")].filter( (element) => element.getAnimations().length > 0, ); expect(animated).toHaveLength(0); - const dot = mark.firstElementChild as HTMLElement; - expect(getComputedStyle(dot).backgroundColor).toBe("rgb(241, 46, 47)"); + expect(getComputedStyle(mark).backgroundColor).toBe("rgb(241, 46, 47)"); }); - it("reserves one slot whatever the state, so the label never steps sideways", () => { - const running = mountMark("running").getBoundingClientRect().width; - const failed = mountMark("failed").getBoundingClientRect().width; + it("starts the circle you can see on the pill's own padding, whatever the state", () => { + // The inset the eye compares a leading mark against is the label's trailing one, and that one + // is the pill's padding. A mark boxed wider than its glyph sits further in than that while + // looking correctly centred in the box nobody can see. + for (const bucket of ["running", "failed"] as const) { + const { mark, body } = mountMark(bucket); + // The dot is its own box; the ring is drawn by the rotator inside the frame's halo. + const glyph = bucket === "failed" ? mark : mark.firstElementChild?.firstElementChild; + if (!(glyph instanceof HTMLElement)) { + throw new Error(`${bucket} mark did not render a glyph`); + } - expect(running).toBe(failed); + const style = getComputedStyle(body); + const edge = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.borderLeftWidth); + const inset = glyph.getBoundingClientRect().left - body.getBoundingClientRect().left; + + expect(inset).toBeCloseTo(edge, 1); + } + }); + + it("draws every state it is given, so a running child survives a failed sibling", () => { + const container = mount( + + rows + , + ); + + const segments = [...container.querySelectorAll('[data-testid^="pill-segment-"]')]; + expect(segments.map((segment) => segment.textContent)).toEqual(["1 failed", "1 working"]); + // The ring is the only mark that animates, so its presence proves the second state survived. + const animated = segments[1]?.querySelectorAll("*") ?? []; + expect([...animated].filter((element) => element.getAnimations().length > 0)).toHaveLength(1); }); }); diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 648025f76..a4b2f889a 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -10,7 +10,7 @@ import { type MenuTriggerState, } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; -import { STATUS_RING_FRAME_SIZE } from "@/components/status-ring/geometry"; +import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -40,15 +40,25 @@ export function ComposerTrackBar({ children }: { children: ReactNode }): ReactEl ); } +export interface ComposerTrackPillSegment { + /** Leading mark — a dot, or the running ring. `null` draws the text on its own. */ + bucket: SidebarStateBucket | null; + /** Short enough to stay on one line alongside its siblings: "1 failed", "2/7 tasks", "3". */ + text: string; +} + export interface ComposerTrackPillProps { - /** The whole pill, and short enough to stay on one line: "3 subagents", "2/7 tasks". */ - label: string; + /** + * The whole pill, left to right, at most one segment per state. One segment is the common case; + * a tracker whose children are in several states at once gives each state its own mark so none + * of them is hidden behind the others. + */ + segments: readonly ComposerTrackPillSegment[]; /** Sheet header on compact. Popovers never show one. */ panelTitle: string; testID: string; + /** Spell out anything the segments abbreviate — a bare count reads as nothing out loud. */ accessibilityLabel?: string; - /** Drawn as a leading mark — a dot, or the running ring. `null` leaves the pill text-only. */ - statusBucket?: SidebarStateBucket | null; /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ children: ReactNode; } @@ -70,20 +80,18 @@ const PANEL_MAX_HEIGHT = 440; const PANEL_OFFSET = 12; export function ComposerTrackPill({ - label, + segments, panelTitle, testID, accessibilityLabel, - statusBucket = null, children, }: ComposerTrackPillProps): ReactElement { return ( segment.text).join(" ")} /> ({ expanded: open }), [open]); @@ -139,10 +145,20 @@ function ComposerTrackPillTrigger({ {...ariaExpandedProps} style={pillStyle} > - - - {label} - + + {segments.map((segment, index) => ( + + + + {segment.text} + + + ))} + ); } @@ -250,18 +266,28 @@ export function ComposerTrackRow({ } /** - * The pill's leading state mark. Running is the ring every other running indicator in the app - * uses; the rest are the dot it grows from. + * A segment's state mark. Running is the ring every other running indicator in the app uses; the + * rest are the dot it grows from. + * + * Each one is sized to the glyph you can see rather than to a slot wide enough for the largest of + * them. The mark leads the pill, so a box wider than its glyph is padding on both sides at once: + * it holds the dot off the pill's leading edge while the label runs flush to the trailing one, and + * it opens a gap to its own label as wide as the gap to the next segment. The ring's halo is + * knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off + * too — leaving every mark's visible edge on the same rail whatever state it is in. */ function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null { if (!bucket) { return null; } - return ( - - {bucket === "running" ? : } - - ); + if (bucket === "running") { + return ( + + + + ); + } + return ; } function dotColorStyle(bucket: Exclude) { @@ -330,14 +356,25 @@ const styles = StyleSheet.create((theme) => { borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, }, - // Sized for the widest mark that can land in it, which is the ring rather than the dot. The - // slot is the same width whatever the state, so the label does not step sideways when the last - // child stops running. - mark: { - width: STATUS_RING_FRAME_SIZE, - height: STATUS_RING_FRAME_SIZE, + // Segments sit twice as far apart as a mark sits from its own text, so a count reads as + // belonging to the mark on its left rather than to the words on its right. + segments: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[4], + }, + segment: { + flexDirection: "row", + alignItems: "center", + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[2], + }, + // Trims the ring's halo so the circle you can see is the box, like the dot's box is the dot. + ringMark: { + margin: -STATUS_RING_HALO_INSET, }, dotNeedsInput: statusDot("needs_input"), dotFailed: statusDot("failed"), diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index 7a2738091..7bcbe3d34 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -1,14 +1,9 @@ +import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; -export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [ - "needs_input", - "failed", - "attention", - "running", - "done", -] as const; +export { STATUS_BUCKET_ORDER }; export const STATUS_BUCKET_LABELS: Record = { needs_input: "Needs input", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a77aa65e2..9a3a59421 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1664,7 +1664,7 @@ export const ar: TranslationResources = { title: "الوكلاء الفرعيون", pillLabelOne: "وكيل فرعي واحد", pillLabelMany: "{{count}} وكلاء فرعيين", - pillLabelRunning: "{{count}} قيد التشغيل", + pillLabelWorking: "{{count}} قيد التشغيل", pillLabelFailed: "{{count}} فشلت", pillLabelNeedsInputOne: "واحد يحتاج إدخالاً", pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9c56e970a..c227200b3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1674,7 +1674,7 @@ export const en = { title: "Subagents", pillLabelOne: "1 subagent", pillLabelMany: "{{count}} subagents", - pillLabelRunning: "{{count}} running", + pillLabelWorking: "{{count}} working", pillLabelFailed: "{{count}} failed", pillLabelNeedsInputOne: "1 needs input", pillLabelNeedsInputMany: "{{count}} need input", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 4ea5b2fa6..cae447fa0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1710,7 +1710,7 @@ export const es: TranslationResources = { title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", - pillLabelRunning: "{{count}} en ejecución", + pillLabelWorking: "{{count}} en ejecución", pillLabelFailed: "{{count}} con error", pillLabelNeedsInputOne: "1 necesita datos", pillLabelNeedsInputMany: "{{count}} necesitan datos", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c2daaa0b8..8938b6a84 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1714,7 +1714,7 @@ export const fr: TranslationResources = { title: "Sous-agents", pillLabelOne: "1 sous-agent", pillLabelMany: "{{count}} sous-agents", - pillLabelRunning: "{{count}} en cours", + pillLabelWorking: "{{count}} en cours", pillLabelFailed: "{{count}} en échec", pillLabelNeedsInputOne: "1 attend une réponse", pillLabelNeedsInputMany: "{{count}} attendent une réponse", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e263004e6..79542db15 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1681,7 +1681,7 @@ export const ja: TranslationResources = { title: "サブエージェント", pillLabelOne: "サブエージェント 1 件", pillLabelMany: "サブエージェント {{count}} 件", - pillLabelRunning: "{{count}} 件実行中", + pillLabelWorking: "{{count}} 件実行中", pillLabelFailed: "{{count}} 件失敗", pillLabelNeedsInputOne: "1 件が入力待ち", pillLabelNeedsInputMany: "{{count}} 件が入力待ち", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index fa8d20849..0d8bd066f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1674,7 +1674,7 @@ export const ko: TranslationResources = { title: "하위 에이전트", pillLabelOne: "하위 에이전트 1개", pillLabelMany: "하위 에이전트 {{count}}개", - pillLabelRunning: "{{count}}개 실행 중", + pillLabelWorking: "{{count}}개 실행 중", pillLabelFailed: "{{count}}개 실패", pillLabelNeedsInputOne: "1개 입력 필요", pillLabelNeedsInputMany: "{{count}}개 입력 필요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 79e2a9401..dd1488af6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1696,7 +1696,7 @@ export const ptBR: TranslationResources = { title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", - pillLabelRunning: "{{count}} em execução", + pillLabelWorking: "{{count}} em execução", pillLabelFailed: "{{count}} com falha", pillLabelNeedsInputOne: "1 precisa de resposta", pillLabelNeedsInputMany: "{{count}} precisam de resposta", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ca5a7dc80..c2a7b4a85 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1699,7 +1699,7 @@ export const ru: TranslationResources = { title: "Субагенты", pillLabelOne: "1 субагент", pillLabelMany: "{{count}} субагентов", - pillLabelRunning: "{{count}} в работе", + pillLabelWorking: "{{count}} в работе", pillLabelFailed: "{{count}} с ошибкой", pillLabelNeedsInputOne: "1 ожидает ввода", pillLabelNeedsInputMany: "{{count}} ожидают ввода", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 120cfcd25..b1a70bcb5 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1644,7 +1644,7 @@ export const zhCN: TranslationResources = { title: "Subagent", pillLabelOne: "1 个 subagent", pillLabelMany: "{{count}} 个 subagent", - pillLabelRunning: "{{count}} 个运行中", + pillLabelWorking: "{{count}} 个运行中", pillLabelFailed: "{{count}} 个失败", pillLabelNeedsInputOne: "1 个需要输入", pillLabelNeedsInputMany: "{{count}} 个需要输入", diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 957e6b7df..516e2fafb 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -35,10 +35,10 @@ describe("buildSubagentPillPresentation", () => { const pill = (rows: SubagentRow[]) => buildSubagentPillPresentation(i18n.t, rows); - it("counts the children that are running, not the fan-out", () => { + it("counts the children that are working, not the fan-out", () => { expect(pill([row({ id: "a" }), row({ id: "b", status: "running" })])).toEqual({ - label: "1 running", - statusBucket: "running", + segments: [{ bucket: "running", text: "1 working" }], + accessibilityLabel: "1 working", }); }); @@ -49,32 +49,47 @@ describe("buildSubagentPillPresentation", () => { row({ id: "b", status: "running" }), row({ id: "c" }), ]), - ).toEqual({ label: "2 running", statusBucket: "running" }); + ).toEqual({ + segments: [{ bucket: "running", text: "2 working" }], + accessibilityLabel: "2 working", + }); }); - it("ranks a failed child above a running one, and counts the failures", () => { + it("keeps a working child visible behind a failed one instead of collapsing to the worst", () => { expect( pill([ row({ id: "a", status: "running" }), row({ id: "b", status: "error", requiresAttention: true }), row({ id: "c", status: "error" }), ]), - ).toEqual({ label: "2 failed", statusBucket: "failed" }); + ).toEqual({ + segments: [ + { bucket: "failed", text: "2 failed" }, + { bucket: "running", text: "1 working" }, + ], + accessibilityLabel: "2 failed, 1 working", + }); }); it("names what it opens once every child is done", () => { expect(pill([row({ id: "a" }), row({ id: "b" })])).toEqual({ - label: "2 subagents", - statusBucket: null, + segments: [{ bucket: null, text: "2 subagents" }], + accessibilityLabel: "2 subagents", }); }); it("keeps the singular for a lone child", () => { - expect(pill([row({ id: "a" })])).toEqual({ label: "1 subagent", statusBucket: null }); + expect(pill([row({ id: "a" })])).toEqual({ + segments: [{ bucket: null, text: "1 subagent" }], + accessibilityLabel: "1 subagent", + }); }); it("has nothing to mark without rows", () => { - expect(pill([])).toEqual({ label: "0 subagents", statusBucket: null }); + expect(pill([])).toEqual({ + segments: [{ bucket: null, text: "0 subagents" }], + accessibilityLabel: "0 subagents", + }); }); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 86f748aba..1e2ddbb8d 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,9 +1,7 @@ import type { TFunction } from "i18next"; +import type { ComposerTrackPillSegment } from "@/composer/tracks"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { - aggregateSidebarStateBuckets, - deriveSidebarStateBucket, -} from "@/utils/sidebar-agent-state"; +import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; @@ -44,44 +42,59 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP }; } -/** The one state the collapsed pill shows, and how many children are in it. */ -interface SubagentStatusSummary { - bucket: Exclude; +type ActiveStatusBucket = Exclude; + +/** The sidebar's list order, minus the state that earns no mark. */ +const ACTIVE_STATUS_BUCKET_ORDER = STATUS_BUCKET_ORDER.filter( + (bucket): bucket is ActiveStatusBucket => bucket !== "done", +); + +/** One state the pill reports, and how many children are in it. */ +interface SubagentStatusCount { + bucket: ActiveStatusBucket; count: number; } -/** Everything the collapsed pill draws. Built together so the label and the mark cannot disagree. */ +/** Everything the pill draws. Built together so no mark can end up next to another one's count. */ export interface SubagentPillPresentation { - label: string; - statusBucket: SidebarStateBucket | null; + segments: ComposerTrackPillSegment[]; + accessibilityLabel: string; } /** - * What the pill says about a fan-out, and which mark it says it with. + * What the pill says about a fan-out, and which marks it says it with. * - * The pill has room for one mark and one number, and they have to answer the same question. Left - * to itself the mark wins and its state is read onto the number: a spinning ring beside - * "2 subagents" says two of them are running. So while anything is happening the pill names that - * state and counts the children in it, and only falls back to naming the total once nothing is. + * A mark and a number sitting together answer the same question, so the pill cannot collapse a + * mixed fan-out into the most urgent state the way a sidebar project row does: a red dot beside + * "1 failed" over a child that is still working says the fan-out has stopped. Every state present + * gets its own mark and its own count, in the order the sidebar's status groups list them. * - * Which state that is comes from collapsing the children into the most urgent bucket among them — - * the same rule a collapsed project row in the sidebar uses, and for the same reason. + * It stays one line because subagent rows only ever reach three states — see + * `buildSubagentRowPresentationData`, which reports no attention of its own — so the pill is two + * segments at worst, and falls back to naming what it opens once nothing is happening. */ export function buildSubagentPillPresentation( t: TFunction, rows: readonly SubagentRow[], ): SubagentPillPresentation { - const summary = summarizeSubagentStatus(rows); - if (!summary) { - return { label: totalLabel(t, rows.length), statusBucket: null }; + const counts = summarizeSubagentStatus(rows); + if (counts.length === 0) { + const label = totalLabel(t, rows.length); + return { segments: [{ bucket: null, text: label }], accessibilityLabel: label }; } - return { label: statusLabel(t, summary), statusBucket: summary.bucket }; + const labels = counts.map(({ bucket, count }) => statusLabel(t, bucket, count)); + return { + segments: counts.map(({ bucket }, index) => ({ bucket, text: labels[index] ?? "" })), + // Marks separate the segments on screen; a screen reader needs the pause spelled out. + accessibilityLabel: labels.join(", "), + }; } -function statusLabel(t: TFunction, { bucket, count }: SubagentStatusSummary): string { +/** Wording comes from the sidebar's status groups — one name per state across the whole app. */ +function statusLabel(t: TFunction, bucket: ActiveStatusBucket, count: number): string { switch (bucket) { case "running": - return t("subagents.pillLabelRunning", { count }); + return t("subagents.pillLabelWorking", { count }); case "failed": return t("subagents.pillLabelFailed", { count }); case "needs_input": @@ -99,18 +112,14 @@ function totalLabel(t: TFunction, total: number): string { } /** - * `null` when every child is done: a finished fan-out is not worth a colour above the composer. + * Empty when every child is done: a finished fan-out is not worth a colour above the composer. */ -function summarizeSubagentStatus(rows: readonly SubagentRow[]): SubagentStatusSummary | null { - const buckets = rows.flatMap((row) => { - const bucket = buildSubagentRowPresentationData(row).statusBucket; - return bucket ? [bucket] : []; +function summarizeSubagentStatus(rows: readonly SubagentRow[]): SubagentStatusCount[] { + const buckets = rows.map((row) => buildSubagentRowPresentationData(row).statusBucket); + return ACTIVE_STATUS_BUCKET_ORDER.flatMap((bucket) => { + const count = buckets.filter((candidate) => candidate === bucket).length; + return count > 0 ? [{ bucket, count }] : []; }); - const bucket = aggregateSidebarStateBuckets(buckets); - if (bucket === "done") { - return null; - } - return { bucket, count: buckets.filter((candidate) => candidate === bucket).length }; } export function countFinishedSubagents(rows: readonly SubagentRow[]): number { diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index a078dd0c5..31cbd4a70 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -78,9 +78,9 @@ export function SubagentsTrack({ return ( {showArchiveFinished && onArchiveFinished ? ( 0}> diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts index c166b0276..90620dfce 100644 --- a/packages/app/src/utils/sidebar-agent-state.ts +++ b/packages/app/src/utils/sidebar-agent-state.ts @@ -16,11 +16,10 @@ export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean { } // Most urgent first, for collapsing a project's workspaces into one badge. This is -// deliberately NOT the flat status-list order (STATUS_BUCKET_ORDER in -// hooks/sidebar-status-view-model.ts), which ranks "attention" above "running": on a +// deliberately NOT STATUS_BUCKET_ORDER below, which ranks "attention" above "running": on a // collapsed project row we want an actively-working project to keep showing the loader, -// so "running" outranks "attention" here. Blocked (needs_input) and failed still win over -// both; done stays last. +// so "running" outranks "attention" here. needs_input and failed still win over both; +// done stays last. const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [ "needs_input", "failed", @@ -29,6 +28,19 @@ const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [ "done", ]; +/** + * The order states are listed in when all of them are shown side by side rather than collapsed + * into one — the sidebar's status groups, and the subagent pill's segments. Anything the user has + * to act on comes before anything that is still moving on its own. + */ +export const STATUS_BUCKET_ORDER: readonly SidebarStateBucket[] = [ + "needs_input", + "failed", + "attention", + "running", + "done", +] as const; + /** * Collapses many workspace status buckets into the single most urgent one, so a * collapsed project row can stand in for the child rows it hides.