diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d60837219..1f717663d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 24c8901cf..20c82b1c7 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b5b97aa19..e9ce560a8 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 51bbc620d..6a60080d8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9778459eb..270a9820e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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}}をアーカイブ", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 65af64469..56f57f267 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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}} 보관", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 92ff52afe..e91bb66fb 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index b1573bfa4..1fdcaf44a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 046b6fc8a..8371118eb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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}}", diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index d7a51daab..957e6b7df 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -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 }); }); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index c79f93c06..86f748aba 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -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; + 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 { diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 940a2cab5..9db487118 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -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 ( {rows.map((row) => (