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
|
// 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.
|
// 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 label = t("message.todo.tasksProgress", { completed, total: tasks.length });
|
||||||
|
const segments = useMemo(() => [{ bucket: null, text: label }], [label]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerTrackPill
|
<ComposerTrackPill
|
||||||
testID="agent-task-list-header"
|
testID="agent-task-list-header"
|
||||||
label={label}
|
segments={segments}
|
||||||
panelTitle={t("message.todo.title")}
|
panelTitle={t("message.todo.title")}
|
||||||
>
|
>
|
||||||
{tasks.map((task, index) => (
|
{tasks.map((task, index) => (
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React, { act, type ReactNode } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { Pressable, Text } from "react-native";
|
import { Pressable, Text } from "react-native";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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.
|
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||||
beforeEach(() => vi.stubGlobal("React", React));
|
beforeEach(() => vi.stubGlobal("React", React));
|
||||||
@@ -66,7 +68,7 @@ describe("composer track panel", () => {
|
|||||||
it("dismisses itself when a row is chosen", () => {
|
it("dismisses itself when a row is chosen", () => {
|
||||||
const onPress = vi.fn();
|
const onPress = vi.fn();
|
||||||
mount(
|
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}>
|
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={onPress}>
|
||||||
<Text>Subagent one</Text>
|
<Text>Subagent one</Text>
|
||||||
</ComposerTrackRow>
|
</ComposerTrackRow>
|
||||||
@@ -87,7 +89,7 @@ describe("composer track panel", () => {
|
|||||||
it("stays open for a row whose result lands in the panel", () => {
|
it("stays open for a row whose result lands in the panel", () => {
|
||||||
const onPress = vi.fn();
|
const onPress = vi.fn();
|
||||||
mount(
|
mount(
|
||||||
<ComposerTrackPill testID="pill" label="3 subagents" panelTitle="Subagents">
|
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
|
||||||
<ComposerTrackRow
|
<ComposerTrackRow
|
||||||
accessibilityLabel="Archive finished"
|
accessibilityLabel="Archive finished"
|
||||||
testID="row"
|
testID="row"
|
||||||
@@ -111,7 +113,7 @@ describe("composer track panel", () => {
|
|||||||
const onPress = vi.fn();
|
const onPress = vi.fn();
|
||||||
const onAction = vi.fn();
|
const onAction = vi.fn();
|
||||||
mount(
|
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}>
|
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={onPress}>
|
||||||
<Text>Subagent one</Text>
|
<Text>Subagent one</Text>
|
||||||
<Pressable accessibilityRole="button" testID="row-archive" onPress={onAction}>
|
<Pressable accessibilityRole="button" testID="row-archive" onPress={onAction}>
|
||||||
@@ -131,26 +133,27 @@ describe("composer track panel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("composer track pill status mark", () => {
|
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(
|
const container = mount(
|
||||||
<ComposerTrackPill
|
<ComposerTrackPill
|
||||||
testID="pill"
|
testID="pill"
|
||||||
label="3 subagents"
|
segments={[{ bucket, text: "3 subagents" }]}
|
||||||
panelTitle="Subagents"
|
panelTitle="Subagents"
|
||||||
statusBucket={statusBucket}
|
|
||||||
>
|
>
|
||||||
<Text>rows</Text>
|
<Text>rows</Text>
|
||||||
</ComposerTrackPill>,
|
</ComposerTrackPill>,
|
||||||
);
|
);
|
||||||
const mark = container.querySelector('[data-testid="pill"] > div');
|
const segment = container.querySelector('[data-testid="pill-segment-0"]');
|
||||||
if (!(mark instanceof HTMLElement)) {
|
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");
|
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", () => {
|
it("spins the shared ring while a child is running", () => {
|
||||||
const mark = mountMark("running");
|
const { mark } = mountMark("running");
|
||||||
const animated = [...mark.querySelectorAll("*")].filter(
|
const animated = [...mark.querySelectorAll("*")].filter(
|
||||||
(element) => element.getAnimations().length > 0,
|
(element) => element.getAnimations().length > 0,
|
||||||
);
|
);
|
||||||
@@ -164,20 +167,53 @@ describe("composer track pill status mark", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("draws a still dot for every other state", () => {
|
it("draws a still dot for every other state", () => {
|
||||||
const mark = mountMark("failed");
|
const { mark } = mountMark("failed");
|
||||||
const animated = [...mark.querySelectorAll("*")].filter(
|
const animated = [...mark.querySelectorAll("*")].filter(
|
||||||
(element) => element.getAnimations().length > 0,
|
(element) => element.getAnimations().length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(animated).toHaveLength(0);
|
expect(animated).toHaveLength(0);
|
||||||
const dot = mark.firstElementChild as HTMLElement;
|
expect(getComputedStyle(mark).backgroundColor).toBe("rgb(241, 46, 47)");
|
||||||
expect(getComputedStyle(dot).backgroundColor).toBe("rgb(241, 46, 47)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reserves one slot whatever the state, so the label never steps sideways", () => {
|
it("starts the circle you can see on the pill's own padding, whatever the state", () => {
|
||||||
const running = mountMark("running").getBoundingClientRect().width;
|
// The inset the eye compares a leading mark against is the label's trailing one, and that one
|
||||||
const failed = mountMark("failed").getBoundingClientRect().width;
|
// 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,
|
type MenuTriggerState,
|
||||||
} from "@/components/ui/menu";
|
} from "@/components/ui/menu";
|
||||||
import { StatusRing } from "@/components/status-ring";
|
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 { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
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 {
|
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. */
|
/** Sheet header on compact. Popovers never show one. */
|
||||||
panelTitle: string;
|
panelTitle: string;
|
||||||
testID: string;
|
testID: string;
|
||||||
|
/** Spell out anything the segments abbreviate — a bare count reads as nothing out loud. */
|
||||||
accessibilityLabel?: string;
|
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. */
|
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -70,20 +80,18 @@ const PANEL_MAX_HEIGHT = 440;
|
|||||||
const PANEL_OFFSET = 12;
|
const PANEL_OFFSET = 12;
|
||||||
|
|
||||||
export function ComposerTrackPill({
|
export function ComposerTrackPill({
|
||||||
label,
|
segments,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
testID,
|
testID,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
statusBucket = null,
|
|
||||||
children,
|
children,
|
||||||
}: ComposerTrackPillProps): ReactElement {
|
}: ComposerTrackPillProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<MenuRoot compactMode="sheet">
|
<MenuRoot compactMode="sheet">
|
||||||
<ComposerTrackPillTrigger
|
<ComposerTrackPillTrigger
|
||||||
label={label}
|
segments={segments}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
accessibilityLabel={accessibilityLabel ?? label}
|
accessibilityLabel={accessibilityLabel ?? segments.map((segment) => segment.text).join(" ")}
|
||||||
statusBucket={statusBucket}
|
|
||||||
/>
|
/>
|
||||||
<MenuSurface
|
<MenuSurface
|
||||||
side="top"
|
side="top"
|
||||||
@@ -103,15 +111,13 @@ export function ComposerTrackPill({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ComposerTrackPillTrigger({
|
function ComposerTrackPillTrigger({
|
||||||
label,
|
segments,
|
||||||
testID,
|
testID,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
statusBucket,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
segments: readonly ComposerTrackPillSegment[];
|
||||||
testID: string;
|
testID: string;
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
statusBucket: SidebarStateBucket | null;
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { open } = useMenuContext("ComposerTrackPill");
|
const { open } = useMenuContext("ComposerTrackPill");
|
||||||
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
|
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
|
||||||
@@ -139,10 +145,20 @@ function ComposerTrackPillTrigger({
|
|||||||
{...ariaExpandedProps}
|
{...ariaExpandedProps}
|
||||||
style={pillStyle}
|
style={pillStyle}
|
||||||
>
|
>
|
||||||
<ComposerTrackMark bucket={statusBucket} />
|
<View style={styles.segments}>
|
||||||
<Text style={labelStyle} numberOfLines={1}>
|
{segments.map((segment, index) => (
|
||||||
{label}
|
<View
|
||||||
</Text>
|
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>
|
</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
|
* A segment's state mark. Running is the ring every other running indicator in the app uses; the
|
||||||
* uses; the rest are the dot it grows from.
|
* 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 {
|
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
if (bucket === "running") {
|
||||||
<View style={styles.mark}>
|
return (
|
||||||
{bucket === "running" ? <StatusRing /> : <View style={dotColorStyle(bucket)} />}
|
<View style={styles.ringMark}>
|
||||||
</View>
|
<StatusRing />
|
||||||
);
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <View style={dotColorStyle(bucket)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dotColorStyle(bucket: Exclude<SidebarStateBucket, "running">) {
|
function dotColorStyle(bucket: Exclude<SidebarStateBucket, "running">) {
|
||||||
@@ -330,14 +356,25 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
// Sized for the widest mark that can land in it, which is the ring rather than the dot. The
|
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
|
||||||
// slot is the same width whatever the state, so the label does not step sideways when the last
|
// belonging to the mark on its left rather than to the words on its right.
|
||||||
// child stops running.
|
segments: {
|
||||||
mark: {
|
flexDirection: "row",
|
||||||
width: STATUS_RING_FRAME_SIZE,
|
|
||||||
height: STATUS_RING_FRAME_SIZE,
|
|
||||||
alignItems: "center",
|
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"),
|
dotNeedsInput: statusDot("needs_input"),
|
||||||
dotFailed: statusDot("failed"),
|
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";
|
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||||
|
|
||||||
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||||
|
|
||||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
export { STATUS_BUCKET_ORDER };
|
||||||
"needs_input",
|
|
||||||
"failed",
|
|
||||||
"attention",
|
|
||||||
"running",
|
|
||||||
"done",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
||||||
needs_input: "Needs input",
|
needs_input: "Needs input",
|
||||||
|
|||||||
@@ -1664,7 +1664,7 @@ export const ar: TranslationResources = {
|
|||||||
title: "الوكلاء الفرعيون",
|
title: "الوكلاء الفرعيون",
|
||||||
pillLabelOne: "وكيل فرعي واحد",
|
pillLabelOne: "وكيل فرعي واحد",
|
||||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||||
pillLabelRunning: "{{count}} قيد التشغيل",
|
pillLabelWorking: "{{count}} قيد التشغيل",
|
||||||
pillLabelFailed: "{{count}} فشلت",
|
pillLabelFailed: "{{count}} فشلت",
|
||||||
pillLabelNeedsInputOne: "واحد يحتاج إدخالاً",
|
pillLabelNeedsInputOne: "واحد يحتاج إدخالاً",
|
||||||
pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً",
|
pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً",
|
||||||
|
|||||||
@@ -1674,7 +1674,7 @@ export const en = {
|
|||||||
title: "Subagents",
|
title: "Subagents",
|
||||||
pillLabelOne: "1 subagent",
|
pillLabelOne: "1 subagent",
|
||||||
pillLabelMany: "{{count}} subagents",
|
pillLabelMany: "{{count}} subagents",
|
||||||
pillLabelRunning: "{{count}} running",
|
pillLabelWorking: "{{count}} working",
|
||||||
pillLabelFailed: "{{count}} failed",
|
pillLabelFailed: "{{count}} failed",
|
||||||
pillLabelNeedsInputOne: "1 needs input",
|
pillLabelNeedsInputOne: "1 needs input",
|
||||||
pillLabelNeedsInputMany: "{{count}} need input",
|
pillLabelNeedsInputMany: "{{count}} need input",
|
||||||
|
|||||||
@@ -1710,7 +1710,7 @@ export const es: TranslationResources = {
|
|||||||
title: "Subagentes",
|
title: "Subagentes",
|
||||||
pillLabelOne: "1 subagente",
|
pillLabelOne: "1 subagente",
|
||||||
pillLabelMany: "{{count}} subagentes",
|
pillLabelMany: "{{count}} subagentes",
|
||||||
pillLabelRunning: "{{count}} en ejecución",
|
pillLabelWorking: "{{count}} en ejecución",
|
||||||
pillLabelFailed: "{{count}} con error",
|
pillLabelFailed: "{{count}} con error",
|
||||||
pillLabelNeedsInputOne: "1 necesita datos",
|
pillLabelNeedsInputOne: "1 necesita datos",
|
||||||
pillLabelNeedsInputMany: "{{count}} necesitan datos",
|
pillLabelNeedsInputMany: "{{count}} necesitan datos",
|
||||||
|
|||||||
@@ -1714,7 +1714,7 @@ export const fr: TranslationResources = {
|
|||||||
title: "Sous-agents",
|
title: "Sous-agents",
|
||||||
pillLabelOne: "1 sous-agent",
|
pillLabelOne: "1 sous-agent",
|
||||||
pillLabelMany: "{{count}} sous-agents",
|
pillLabelMany: "{{count}} sous-agents",
|
||||||
pillLabelRunning: "{{count}} en cours",
|
pillLabelWorking: "{{count}} en cours",
|
||||||
pillLabelFailed: "{{count}} en échec",
|
pillLabelFailed: "{{count}} en échec",
|
||||||
pillLabelNeedsInputOne: "1 attend une réponse",
|
pillLabelNeedsInputOne: "1 attend une réponse",
|
||||||
pillLabelNeedsInputMany: "{{count}} attendent une réponse",
|
pillLabelNeedsInputMany: "{{count}} attendent une réponse",
|
||||||
|
|||||||
@@ -1681,7 +1681,7 @@ export const ja: TranslationResources = {
|
|||||||
title: "サブエージェント",
|
title: "サブエージェント",
|
||||||
pillLabelOne: "サブエージェント 1 件",
|
pillLabelOne: "サブエージェント 1 件",
|
||||||
pillLabelMany: "サブエージェント {{count}} 件",
|
pillLabelMany: "サブエージェント {{count}} 件",
|
||||||
pillLabelRunning: "{{count}} 件実行中",
|
pillLabelWorking: "{{count}} 件実行中",
|
||||||
pillLabelFailed: "{{count}} 件失敗",
|
pillLabelFailed: "{{count}} 件失敗",
|
||||||
pillLabelNeedsInputOne: "1 件が入力待ち",
|
pillLabelNeedsInputOne: "1 件が入力待ち",
|
||||||
pillLabelNeedsInputMany: "{{count}} 件が入力待ち",
|
pillLabelNeedsInputMany: "{{count}} 件が入力待ち",
|
||||||
|
|||||||
@@ -1674,7 +1674,7 @@ export const ko: TranslationResources = {
|
|||||||
title: "하위 에이전트",
|
title: "하위 에이전트",
|
||||||
pillLabelOne: "하위 에이전트 1개",
|
pillLabelOne: "하위 에이전트 1개",
|
||||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||||
pillLabelRunning: "{{count}}개 실행 중",
|
pillLabelWorking: "{{count}}개 실행 중",
|
||||||
pillLabelFailed: "{{count}}개 실패",
|
pillLabelFailed: "{{count}}개 실패",
|
||||||
pillLabelNeedsInputOne: "1개 입력 필요",
|
pillLabelNeedsInputOne: "1개 입력 필요",
|
||||||
pillLabelNeedsInputMany: "{{count}}개 입력 필요",
|
pillLabelNeedsInputMany: "{{count}}개 입력 필요",
|
||||||
|
|||||||
@@ -1696,7 +1696,7 @@ export const ptBR: TranslationResources = {
|
|||||||
title: "Subagentes",
|
title: "Subagentes",
|
||||||
pillLabelOne: "1 subagente",
|
pillLabelOne: "1 subagente",
|
||||||
pillLabelMany: "{{count}} subagentes",
|
pillLabelMany: "{{count}} subagentes",
|
||||||
pillLabelRunning: "{{count}} em execução",
|
pillLabelWorking: "{{count}} em execução",
|
||||||
pillLabelFailed: "{{count}} com falha",
|
pillLabelFailed: "{{count}} com falha",
|
||||||
pillLabelNeedsInputOne: "1 precisa de resposta",
|
pillLabelNeedsInputOne: "1 precisa de resposta",
|
||||||
pillLabelNeedsInputMany: "{{count}} precisam de resposta",
|
pillLabelNeedsInputMany: "{{count}} precisam de resposta",
|
||||||
|
|||||||
@@ -1699,7 +1699,7 @@ export const ru: TranslationResources = {
|
|||||||
title: "Субагенты",
|
title: "Субагенты",
|
||||||
pillLabelOne: "1 субагент",
|
pillLabelOne: "1 субагент",
|
||||||
pillLabelMany: "{{count}} субагентов",
|
pillLabelMany: "{{count}} субагентов",
|
||||||
pillLabelRunning: "{{count}} в работе",
|
pillLabelWorking: "{{count}} в работе",
|
||||||
pillLabelFailed: "{{count}} с ошибкой",
|
pillLabelFailed: "{{count}} с ошибкой",
|
||||||
pillLabelNeedsInputOne: "1 ожидает ввода",
|
pillLabelNeedsInputOne: "1 ожидает ввода",
|
||||||
pillLabelNeedsInputMany: "{{count}} ожидают ввода",
|
pillLabelNeedsInputMany: "{{count}} ожидают ввода",
|
||||||
|
|||||||
@@ -1644,7 +1644,7 @@ export const zhCN: TranslationResources = {
|
|||||||
title: "Subagent",
|
title: "Subagent",
|
||||||
pillLabelOne: "1 个 subagent",
|
pillLabelOne: "1 个 subagent",
|
||||||
pillLabelMany: "{{count}} 个 subagent",
|
pillLabelMany: "{{count}} 个 subagent",
|
||||||
pillLabelRunning: "{{count}} 个运行中",
|
pillLabelWorking: "{{count}} 个运行中",
|
||||||
pillLabelFailed: "{{count}} 个失败",
|
pillLabelFailed: "{{count}} 个失败",
|
||||||
pillLabelNeedsInputOne: "1 个需要输入",
|
pillLabelNeedsInputOne: "1 个需要输入",
|
||||||
pillLabelNeedsInputMany: "{{count}} 个需要输入",
|
pillLabelNeedsInputMany: "{{count}} 个需要输入",
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ describe("buildSubagentPillPresentation", () => {
|
|||||||
|
|
||||||
const pill = (rows: SubagentRow[]) => buildSubagentPillPresentation(i18n.t, rows);
|
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({
|
expect(pill([row({ id: "a" }), row({ id: "b", status: "running" })])).toEqual({
|
||||||
label: "1 running",
|
segments: [{ bucket: "running", text: "1 working" }],
|
||||||
statusBucket: "running",
|
accessibilityLabel: "1 working",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,32 +49,47 @@ describe("buildSubagentPillPresentation", () => {
|
|||||||
row({ id: "b", status: "running" }),
|
row({ id: "b", status: "running" }),
|
||||||
row({ id: "c" }),
|
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(
|
expect(
|
||||||
pill([
|
pill([
|
||||||
row({ id: "a", status: "running" }),
|
row({ id: "a", status: "running" }),
|
||||||
row({ id: "b", status: "error", requiresAttention: true }),
|
row({ id: "b", status: "error", requiresAttention: true }),
|
||||||
row({ id: "c", status: "error" }),
|
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", () => {
|
it("names what it opens once every child is done", () => {
|
||||||
expect(pill([row({ id: "a" }), row({ id: "b" })])).toEqual({
|
expect(pill([row({ id: "a" }), row({ id: "b" })])).toEqual({
|
||||||
label: "2 subagents",
|
segments: [{ bucket: null, text: "2 subagents" }],
|
||||||
statusBucket: null,
|
accessibilityLabel: "2 subagents",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the singular for a lone child", () => {
|
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", () => {
|
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 { TFunction } from "i18next";
|
||||||
|
import type { ComposerTrackPillSegment } from "@/composer/tracks";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import {
|
import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
|
||||||
aggregateSidebarStateBuckets,
|
|
||||||
deriveSidebarStateBucket,
|
|
||||||
} from "@/utils/sidebar-agent-state";
|
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
import { isFinishedSubagent } from "./archive-finished";
|
import { isFinishedSubagent } from "./archive-finished";
|
||||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
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. */
|
type ActiveStatusBucket = Exclude<SidebarStateBucket, "done">;
|
||||||
interface SubagentStatusSummary {
|
|
||||||
bucket: 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;
|
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 {
|
export interface SubagentPillPresentation {
|
||||||
label: string;
|
segments: ComposerTrackPillSegment[];
|
||||||
statusBucket: SidebarStateBucket | null;
|
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
|
* A mark and a number sitting together answer the same question, so the pill cannot collapse a
|
||||||
* to itself the mark wins and its state is read onto the number: a spinning ring beside
|
* mixed fan-out into the most urgent state the way a sidebar project row does: a red dot beside
|
||||||
* "2 subagents" says two of them are running. So while anything is happening the pill names that
|
* "1 failed" over a child that is still working says the fan-out has stopped. Every state present
|
||||||
* state and counts the children in it, and only falls back to naming the total once nothing is.
|
* 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 —
|
* It stays one line because subagent rows only ever reach three states — see
|
||||||
* the same rule a collapsed project row in the sidebar uses, and for the same reason.
|
* `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(
|
export function buildSubagentPillPresentation(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
rows: readonly SubagentRow[],
|
rows: readonly SubagentRow[],
|
||||||
): SubagentPillPresentation {
|
): SubagentPillPresentation {
|
||||||
const summary = summarizeSubagentStatus(rows);
|
const counts = summarizeSubagentStatus(rows);
|
||||||
if (!summary) {
|
if (counts.length === 0) {
|
||||||
return { label: totalLabel(t, rows.length), statusBucket: null };
|
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) {
|
switch (bucket) {
|
||||||
case "running":
|
case "running":
|
||||||
return t("subagents.pillLabelRunning", { count });
|
return t("subagents.pillLabelWorking", { count });
|
||||||
case "failed":
|
case "failed":
|
||||||
return t("subagents.pillLabelFailed", { count });
|
return t("subagents.pillLabelFailed", { count });
|
||||||
case "needs_input":
|
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 {
|
function summarizeSubagentStatus(rows: readonly SubagentRow[]): SubagentStatusCount[] {
|
||||||
const buckets = rows.flatMap((row) => {
|
const buckets = rows.map((row) => buildSubagentRowPresentationData(row).statusBucket);
|
||||||
const bucket = buildSubagentRowPresentationData(row).statusBucket;
|
return ACTIVE_STATUS_BUCKET_ORDER.flatMap((bucket) => {
|
||||||
return bucket ? [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 {
|
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ export function SubagentsTrack({
|
|||||||
return (
|
return (
|
||||||
<ComposerTrackPill
|
<ComposerTrackPill
|
||||||
testID="subagents-track-header"
|
testID="subagents-track-header"
|
||||||
label={pill.label}
|
segments={pill.segments}
|
||||||
|
accessibilityLabel={pill.accessibilityLabel}
|
||||||
panelTitle={t("subagents.title")}
|
panelTitle={t("subagents.title")}
|
||||||
statusBucket={pill.statusBucket}
|
|
||||||
>
|
>
|
||||||
{showArchiveFinished && onArchiveFinished ? (
|
{showArchiveFinished && onArchiveFinished ? (
|
||||||
<ComposerTrackActions divided={rows.length > 0}>
|
<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
|
// 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
|
// deliberately NOT STATUS_BUCKET_ORDER below, which ranks "attention" above "running": on a
|
||||||
// hooks/sidebar-status-view-model.ts), which ranks "attention" above "running": on a
|
|
||||||
// collapsed project row we want an actively-working project to keep showing the loader,
|
// 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
|
// so "running" outranks "attention" here. needs_input and failed still win over both;
|
||||||
// both; done stays last.
|
// done stays last.
|
||||||
const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [
|
const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [
|
||||||
"needs_input",
|
"needs_input",
|
||||||
"failed",
|
"failed",
|
||||||
@@ -29,6 +28,19 @@ const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [
|
|||||||
"done",
|
"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
|
* 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.
|
* collapsed project row can stand in for the child rows it hides.
|
||||||
|
|||||||
Reference in New Issue
Block a user