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);
});
});
+69 -32
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} />
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
<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}>
{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;
}
return (
<View style={styles.mark}>
{bucket === "running" ? <StatusRing /> : <View style={dotColorStyle(bucket)} />}
</View>
);
if (bucket === "running") {
return (
<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"),