feat(app): give composer track pills a segment per state

A mixed fan-out used to collapse the pill to its single most urgent
state, so a red dot beside "1 failed" over a child that was still
working read as the whole fan-out having stopped. Every state present
now gets its own mark and count, in the sidebar's status-group order.
This commit is contained in:
Mohamed Boudra
2026-08-18 21:51:25 +02:00
parent 5731f016c2
commit 25fbc939e0
17 changed files with 221 additions and 116 deletions
@@ -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 (
<ComposerTrackPill
testID="agent-task-list-header"
label={label}
segments={segments}
panelTitle={t("message.todo.title")}
>
{tasks.map((task, index) => (
@@ -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(
<ComposerTrackPill testID="pill" label="3 subagents" panelTitle="Subagents">
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={onPress}>
<Text>Subagent one</Text>
</ComposerTrackRow>
@@ -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(
<ComposerTrackPill testID="pill" label="3 subagents" panelTitle="Subagents">
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow
accessibilityLabel="Archive finished"
testID="row"
@@ -111,7 +113,7 @@ describe("composer track panel", () => {
const onPress = vi.fn();
const onAction = vi.fn();
mount(
<ComposerTrackPill testID="pill" label="3 subagents" panelTitle="Subagents">
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={onPress}>
<Text>Subagent one</Text>
<Pressable accessibilityRole="button" testID="row-archive" onPress={onAction}>
@@ -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(
<ComposerTrackPill
testID="pill"
label="3 subagents"
segments={[{ bucket, text: "3 subagents" }]}
panelTitle="Subagents"
statusBucket={statusBucket}
>
<Text>rows</Text>
</ComposerTrackPill>,
);
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(
<ComposerTrackPill
testID="pill"
segments={[
{ bucket: "failed", text: "1 failed" },
{ bucket: "running", text: "1 working" },
]}
panelTitle="Subagents"
>
<Text>rows</Text>
</ComposerTrackPill>,
);
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);
});
});
+64 -27
View File
@@ -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 (
<MenuRoot compactMode="sheet">
<ComposerTrackPillTrigger
label={label}
segments={segments}
testID={testID}
accessibilityLabel={accessibilityLabel ?? label}
statusBucket={statusBucket}
accessibilityLabel={accessibilityLabel ?? segments.map((segment) => segment.text).join(" ")}
/>
<MenuSurface
side="top"
@@ -103,15 +111,13 @@ export function ComposerTrackPill({
}
function ComposerTrackPillTrigger({
label,
segments,
testID,
accessibilityLabel,
statusBucket,
}: {
label: string;
segments: readonly ComposerTrackPillSegment[];
testID: string;
accessibilityLabel: string;
statusBucket: SidebarStateBucket | null;
}): ReactElement {
const { open } = useMenuContext("ComposerTrackPill");
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
@@ -139,10 +145,20 @@ function ComposerTrackPillTrigger({
{...ariaExpandedProps}
style={pillStyle}
>
<ComposerTrackMark bucket={statusBucket} />
<View style={styles.segments}>
{segments.map((segment, index) => (
<View
key={segment.bucket ?? "plain"}
style={styles.segment}
testID={`${testID}-segment-${index}`}
>
<ComposerTrackMark bucket={segment.bucket} />
<Text style={labelStyle} numberOfLines={1}>
{label}
{segment.text}
</Text>
</View>
))}
</View>
</MenuTrigger>
);
}
@@ -250,19 +266,29 @@ 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;
}
if (bucket === "running") {
return (
<View style={styles.mark}>
{bucket === "running" ? <StatusRing /> : <View style={dotColorStyle(bucket)} />}
<View style={styles.ringMark}>
<StatusRing />
</View>
);
}
return <View style={dotColorStyle(bucket)} />;
}
function dotColorStyle(bucket: Exclude<SidebarStateBucket, "running">) {
switch (bucket) {
@@ -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"),
@@ -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<StatusBucket, string> = {
needs_input: "Needs input",
+1 -1
View File
@@ -1664,7 +1664,7 @@ export const ar: TranslationResources = {
title: "الوكلاء الفرعيون",
pillLabelOne: "وكيل فرعي واحد",
pillLabelMany: "{{count}} وكلاء فرعيين",
pillLabelRunning: "{{count}} قيد التشغيل",
pillLabelWorking: "{{count}} قيد التشغيل",
pillLabelFailed: "{{count}} فشلت",
pillLabelNeedsInputOne: "واحد يحتاج إدخالاً",
pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1681,7 +1681,7 @@ export const ja: TranslationResources = {
title: "サブエージェント",
pillLabelOne: "サブエージェント 1 件",
pillLabelMany: "サブエージェント {{count}} 件",
pillLabelRunning: "{{count}} 件実行中",
pillLabelWorking: "{{count}} 件実行中",
pillLabelFailed: "{{count}} 件失敗",
pillLabelNeedsInputOne: "1 件が入力待ち",
pillLabelNeedsInputMany: "{{count}} 件が入力待ち",
+1 -1
View File
@@ -1674,7 +1674,7 @@ export const ko: TranslationResources = {
title: "하위 에이전트",
pillLabelOne: "하위 에이전트 1개",
pillLabelMany: "하위 에이전트 {{count}}개",
pillLabelRunning: "{{count}}개 실행 중",
pillLabelWorking: "{{count}}개 실행 중",
pillLabelFailed: "{{count}}개 실패",
pillLabelNeedsInputOne: "1개 입력 필요",
pillLabelNeedsInputMany: "{{count}}개 입력 필요",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1699,7 +1699,7 @@ export const ru: TranslationResources = {
title: "Субагенты",
pillLabelOne: "1 субагент",
pillLabelMany: "{{count}} субагентов",
pillLabelRunning: "{{count}} в работе",
pillLabelWorking: "{{count}} в работе",
pillLabelFailed: "{{count}} с ошибкой",
pillLabelNeedsInputOne: "1 ожидает ввода",
pillLabelNeedsInputMany: "{{count}} ожидают ввода",
+1 -1
View File
@@ -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}} 个需要输入",
@@ -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",
});
});
});
@@ -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<SidebarStateBucket, "done">;
type ActiveStatusBucket = Exclude<SidebarStateBucket, "done">;
/** 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 {
+2 -2
View File
@@ -78,9 +78,9 @@ export function SubagentsTrack({
return (
<ComposerTrackPill
testID="subagents-track-header"
label={pill.label}
segments={pill.segments}
accessibilityLabel={pill.accessibilityLabel}
panelTitle={t("subagents.title")}
statusBucket={pill.statusBucket}
>
{showArchiveFinished && onArchiveFinished ? (
<ComposerTrackActions divided={rows.length > 0}>
+16 -4
View File
@@ -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.