fix(app): count what the subagents pill's mark is showing
The pill drew the most urgent state of the fan-out next to the size of the fan-out. Two questions, one pill, 8pt apart: a spinning ring beside "2 subagents" reads as two agents running when one is. The mark is the thing the eye lands on, so its state gets read onto whatever number is next to it. While anything is happening the pill now names that state and counts the children in it — "1 running", "2 failed" — and falls back to naming the total once nothing is. The total is a tap away in the panel, and the running count is the number worth glancing at anyway. Label and mark are built by one function so they cannot disagree; the call site takes both or neither. Every non-done bucket gets copy, not just the two that are reachable today, because needs_input and attention become reachable the moment a subagent row carries requiresAttention, and the failure mode is silently the bug this commit fixes.
This commit is contained in:
@@ -1624,6 +1624,11 @@ export const ar: TranslationResources = {
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
pillLabelRunning: "{{count}} قيد التشغيل",
|
||||
pillLabelFailed: "{{count}} فشلت",
|
||||
pillLabelNeedsInputOne: "واحد يحتاج إدخالاً",
|
||||
pillLabelNeedsInputMany: "{{count}} تحتاج إدخالاً",
|
||||
pillLabelReadyToReview: "{{count}} جاهزة للمراجعة",
|
||||
detachAction: "فصل {{label}}",
|
||||
detachTooltip: "فصل الوكيل الفرعي",
|
||||
archiveAction: "أرشيف{{label}}",
|
||||
|
||||
@@ -1634,6 +1634,11 @@ export const en = {
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
pillLabelRunning: "{{count}} running",
|
||||
pillLabelFailed: "{{count}} failed",
|
||||
pillLabelNeedsInputOne: "1 needs input",
|
||||
pillLabelNeedsInputMany: "{{count}} need input",
|
||||
pillLabelReadyToReview: "{{count}} ready to review",
|
||||
detachAction: "Detach {{label}}",
|
||||
detachTooltip: "Detach subagent",
|
||||
archiveAction: "Archive {{label}}",
|
||||
|
||||
@@ -1667,6 +1667,11 @@ export const es: TranslationResources = {
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
pillLabelRunning: "{{count}} en ejecución",
|
||||
pillLabelFailed: "{{count}} con error",
|
||||
pillLabelNeedsInputOne: "1 necesita datos",
|
||||
pillLabelNeedsInputMany: "{{count}} necesitan datos",
|
||||
pillLabelReadyToReview: "{{count}} para revisar",
|
||||
detachAction: "Separar {{label}}",
|
||||
detachTooltip: "Separar subagente",
|
||||
archiveAction: "Archivo{{label}}",
|
||||
|
||||
@@ -1671,6 +1671,11 @@ export const fr: TranslationResources = {
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
pillLabelRunning: "{{count}} en cours",
|
||||
pillLabelFailed: "{{count}} en échec",
|
||||
pillLabelNeedsInputOne: "1 attend une réponse",
|
||||
pillLabelNeedsInputMany: "{{count}} attendent une réponse",
|
||||
pillLabelReadyToReview: "{{count}} à relire",
|
||||
detachAction: "Detacher {{label}}",
|
||||
detachTooltip: "Detacher le sous-agent",
|
||||
archiveAction: "Archiver{{label}}",
|
||||
|
||||
@@ -1640,6 +1640,11 @@ export const ja: TranslationResources = {
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
pillLabelRunning: "{{count}} 件実行中",
|
||||
pillLabelFailed: "{{count}} 件失敗",
|
||||
pillLabelNeedsInputOne: "1 件が入力待ち",
|
||||
pillLabelNeedsInputMany: "{{count}} 件が入力待ち",
|
||||
pillLabelReadyToReview: "{{count}} 件レビュー待ち",
|
||||
detachAction: "{{label}}を切り離す",
|
||||
detachTooltip: "サブエージェントを切り離す",
|
||||
archiveAction: "{{label}}をアーカイブ",
|
||||
|
||||
@@ -1634,6 +1634,11 @@ export const ko: TranslationResources = {
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
pillLabelRunning: "{{count}}개 실행 중",
|
||||
pillLabelFailed: "{{count}}개 실패",
|
||||
pillLabelNeedsInputOne: "1개 입력 필요",
|
||||
pillLabelNeedsInputMany: "{{count}}개 입력 필요",
|
||||
pillLabelReadyToReview: "{{count}}개 검토 대기",
|
||||
detachAction: "{{label}} 분리",
|
||||
detachTooltip: "하위 에이전트 분리",
|
||||
archiveAction: "{{label}} 보관",
|
||||
|
||||
@@ -1653,6 +1653,11 @@ export const ptBR: TranslationResources = {
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
pillLabelRunning: "{{count}} em execução",
|
||||
pillLabelFailed: "{{count}} com falha",
|
||||
pillLabelNeedsInputOne: "1 precisa de resposta",
|
||||
pillLabelNeedsInputMany: "{{count}} precisam de resposta",
|
||||
pillLabelReadyToReview: "{{count}} para revisar",
|
||||
detachAction: "Desanexar {{label}}",
|
||||
detachTooltip: "Desanexar subagente",
|
||||
archiveAction: "Arquivar {{label}}",
|
||||
|
||||
@@ -1658,6 +1658,11 @@ export const ru: TranslationResources = {
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "{{count}} субагентов",
|
||||
pillLabelRunning: "{{count}} в работе",
|
||||
pillLabelFailed: "{{count}} с ошибкой",
|
||||
pillLabelNeedsInputOne: "1 ожидает ввода",
|
||||
pillLabelNeedsInputMany: "{{count}} ожидают ввода",
|
||||
pillLabelReadyToReview: "{{count}} на проверку",
|
||||
detachAction: "Отсоединить {{label}}",
|
||||
detachTooltip: "Отсоединить субагент",
|
||||
archiveAction: "Архив{{label}}",
|
||||
|
||||
@@ -1604,6 +1604,11 @@ export const zhCN: TranslationResources = {
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
pillLabelRunning: "{{count}} 个运行中",
|
||||
pillLabelFailed: "{{count}} 个失败",
|
||||
pillLabelNeedsInputOne: "1 个需要输入",
|
||||
pillLabelNeedsInputMany: "{{count}} 个需要输入",
|
||||
pillLabelReadyToReview: "{{count}} 个待查看",
|
||||
detachAction: "分离 {{label}}",
|
||||
detachTooltip: "分离 subagent",
|
||||
archiveAction: "归档 {{label}}",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { PaseoSubagentRow, ProviderSubagentRow, SubagentRow } from "./select";
|
||||
import {
|
||||
aggregateSubagentStatusBucket,
|
||||
buildSubagentPillPresentation,
|
||||
buildSubagentRowPresentationData,
|
||||
countFinishedSubagents,
|
||||
resolveRowLabel,
|
||||
@@ -23,28 +24,57 @@ function row(
|
||||
};
|
||||
}
|
||||
|
||||
describe("aggregateSubagentStatusBucket", () => {
|
||||
it("has no bucket without rows", () => {
|
||||
expect(aggregateSubagentStatusBucket([])).toBeNull();
|
||||
describe("buildSubagentPillPresentation", () => {
|
||||
// The real instance, so a label that names a key nobody added renders as that key and fails.
|
||||
beforeAll(async () => {
|
||||
if (!i18n.isInitialized) {
|
||||
await i18n.init();
|
||||
}
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
it("has no bucket when every child is done", () => {
|
||||
expect(aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b" })])).toBeNull();
|
||||
const pill = (rows: SubagentRow[]) => buildSubagentPillPresentation(i18n.t, rows);
|
||||
|
||||
it("counts the children that are running, not the fan-out", () => {
|
||||
expect(pill([row({ id: "a" }), row({ id: "b", status: "running" })])).toEqual({
|
||||
label: "1 running",
|
||||
statusBucket: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports running when any child is running", () => {
|
||||
it("counts every child in the state it reports", () => {
|
||||
expect(
|
||||
aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b", status: "running" })]),
|
||||
).toBe("running");
|
||||
pill([
|
||||
row({ id: "a", status: "running" }),
|
||||
row({ id: "b", status: "running" }),
|
||||
row({ id: "c" }),
|
||||
]),
|
||||
).toEqual({ label: "2 running", statusBucket: "running" });
|
||||
});
|
||||
|
||||
it("ranks a failed child above a running one", () => {
|
||||
it("ranks a failed child above a running one, and counts the failures", () => {
|
||||
expect(
|
||||
aggregateSubagentStatusBucket([
|
||||
pill([
|
||||
row({ id: "a", status: "running" }),
|
||||
row({ id: "b", status: "error", requiresAttention: true }),
|
||||
row({ id: "c", status: "error" }),
|
||||
]),
|
||||
).toBe("failed");
|
||||
).toEqual({ label: "2 failed", statusBucket: "failed" });
|
||||
});
|
||||
|
||||
it("names what it opens once every child is done", () => {
|
||||
expect(pill([row({ id: "a" }), row({ id: "b" })])).toEqual({
|
||||
label: "2 subagents",
|
||||
statusBucket: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the singular for a lone child", () => {
|
||||
expect(pill([row({ id: "a" })])).toEqual({ label: "1 subagent", statusBucket: null });
|
||||
});
|
||||
|
||||
it("has nothing to mark without rows", () => {
|
||||
expect(pill([])).toEqual({ label: "0 subagents", statusBucket: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
aggregateSidebarStateBuckets,
|
||||
@@ -43,25 +44,73 @@ 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">;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Everything the collapsed pill draws. Built together so the label and the mark cannot disagree. */
|
||||
export interface SubagentPillPresentation {
|
||||
label: string;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one state the collapsed pill can show. The pill has room for a dot and a count, so the
|
||||
* children collapse into the most urgent bucket among them — the same rule a collapsed project
|
||||
* row in the sidebar uses, and for the same reason.
|
||||
* What the pill says about a fan-out, and which mark 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function buildSubagentPillPresentation(
|
||||
t: TFunction,
|
||||
rows: readonly SubagentRow[],
|
||||
): SubagentPillPresentation {
|
||||
const summary = summarizeSubagentStatus(rows);
|
||||
if (!summary) {
|
||||
return { label: totalLabel(t, rows.length), statusBucket: null };
|
||||
}
|
||||
return { label: statusLabel(t, summary), statusBucket: summary.bucket };
|
||||
}
|
||||
|
||||
function statusLabel(t: TFunction, { bucket, count }: SubagentStatusSummary): string {
|
||||
switch (bucket) {
|
||||
case "running":
|
||||
return t("subagents.pillLabelRunning", { count });
|
||||
case "failed":
|
||||
return t("subagents.pillLabelFailed", { count });
|
||||
case "needs_input":
|
||||
return count === 1
|
||||
? t("subagents.pillLabelNeedsInputOne")
|
||||
: t("subagents.pillLabelNeedsInputMany", { count });
|
||||
case "attention":
|
||||
return t("subagents.pillLabelReadyToReview", { count });
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing is happening, so the pill is back to naming what it opens. */
|
||||
function totalLabel(t: TFunction, total: number): string {
|
||||
return total === 1 ? t("subagents.pillLabelOne") : t("subagents.pillLabelMany", { count: total });
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` when every child is done: a finished fan-out is not worth a colour above the composer.
|
||||
*/
|
||||
export function aggregateSubagentStatusBucket(
|
||||
rows: readonly SubagentRow[],
|
||||
): SidebarStateBucket | null {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
function summarizeSubagentStatus(rows: readonly SubagentRow[]): SubagentStatusSummary | null {
|
||||
const buckets = rows.flatMap((row) => {
|
||||
const bucket = buildSubagentRowPresentationData(row).statusBucket;
|
||||
return bucket ? [bucket] : [];
|
||||
});
|
||||
const aggregate = aggregateSidebarStateBuckets(buckets);
|
||||
return aggregate === "done" ? null : aggregate;
|
||||
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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
aggregateSubagentStatusBucket,
|
||||
buildSubagentPillPresentation,
|
||||
buildSubagentRowPresentationData,
|
||||
countFinishedSubagents,
|
||||
} from "./track-presentation";
|
||||
@@ -71,19 +71,16 @@ export function SubagentsTrack({
|
||||
return null;
|
||||
}
|
||||
|
||||
const pillLabel =
|
||||
rows.length === 1
|
||||
? t("subagents.pillLabelOne")
|
||||
: t("subagents.pillLabelMany", { count: rows.length });
|
||||
const pill = buildSubagentPillPresentation(t, rows);
|
||||
const finishedCount = countFinishedSubagents(rows);
|
||||
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
testID="subagents-track-header"
|
||||
label={pillLabel}
|
||||
label={pill.label}
|
||||
panelTitle={t("subagents.title")}
|
||||
statusBucket={aggregateSubagentStatusBucket(rows)}
|
||||
statusBucket={pill.statusBucket}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<SubagentsTrackRow
|
||||
|
||||
Reference in New Issue
Block a user