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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
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">) {
|
||||
@@ -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",
|
||||
|
||||
@@ -1664,7 +1664,7 @@ export const ar: TranslationResources = {
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
pillLabelRunning: "{{count}} قيد التشغيل",
|
||||
pillLabelWorking: "{{count}} قيد التشغيل",
|
||||
pillLabelFailed: "{{count}} فشلت",
|
||||
pillLabelNeedsInputOne: "واحد يحتاج إدخالاً",
|
||||
pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1681,7 +1681,7 @@ export const ja: TranslationResources = {
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
pillLabelRunning: "{{count}} 件実行中",
|
||||
pillLabelWorking: "{{count}} 件実行中",
|
||||
pillLabelFailed: "{{count}} 件失敗",
|
||||
pillLabelNeedsInputOne: "1 件が入力待ち",
|
||||
pillLabelNeedsInputMany: "{{count}} 件が入力待ち",
|
||||
|
||||
@@ -1674,7 +1674,7 @@ export const ko: TranslationResources = {
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
pillLabelRunning: "{{count}}개 실행 중",
|
||||
pillLabelWorking: "{{count}}개 실행 중",
|
||||
pillLabelFailed: "{{count}}개 실패",
|
||||
pillLabelNeedsInputOne: "1개 입력 필요",
|
||||
pillLabelNeedsInputMany: "{{count}}개 입력 필요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1699,7 +1699,7 @@ export const ru: TranslationResources = {
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "{{count}} субагентов",
|
||||
pillLabelRunning: "{{count}} в работе",
|
||||
pillLabelWorking: "{{count}} в работе",
|
||||
pillLabelFailed: "{{count}} с ошибкой",
|
||||
pillLabelNeedsInputOne: "1 ожидает ввода",
|
||||
pillLabelNeedsInputMany: "{{count}} ожидают ввода",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user