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} />
|
||||
<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"),
|
||||
|
||||
Reference in New Issue
Block a user