diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 8bd26e21a04dc..162ea55b2b71e 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -15,6 +15,7 @@ import * as DateTime from "effect/DateTime"; import { CircleDashedIcon, CircleStopIcon, + EyeIcon, GitBranchIcon, MessageCircleIcon, ShieldQuestionIcon, @@ -179,29 +180,45 @@ export function backgroundWorkNowItem(input: { const newest = rows .filter((row) => row.activity) .toSorted((left, right) => right.updatedAtMs - left.updatedAtMs)[0]; - const isCommand = (row: (typeof rows)[number]) => row.task.kind !== "subagent"; - const verb = presentation.waiting ? "Waiting on" : "Running"; + const kinds = rows.map((row) => row.task.kind); + // Monitors describe themselves in prose; commands are code. + const isCode = (kind: (typeof kinds)[number]) => kind !== "subagent" && kind !== "monitor"; const onlyThreadId = only?.childThreadId; const startedAtMs = only?.startedAtMs ?? null; return { id: `background-work:${input.threadId}`, - icon: rows.every(isCommand) ? : , - title: only ? ( + icon: kinds.every((kind) => kind === "monitor") ? ( + + ) : kinds.includes("subagent") ? ( + + ) : ( + + ), + title: presentation.subject ? ( <> - {verb} {only.task.label} + {presentation.subject.verb === null ? null : `${presentation.subject.verb} `} + + {presentation.subject.item.label} + ) : ( presentation.title ), - description: only ? ( - only.activity - ) : newest ? ( - `${newest.task.label}: ${newest.activity}` - ) : rows.every(isCommand) ? ( - {rows.map((row) => row.task.label).join(", ")} - ) : ( - rows.map((row) => row.task.label).join(", ") - ), + description: only + ? only.activity + : newest + ? `${newest.task.label}: ${newest.activity}` + : rows.map((row, index) => ( + + {index > 0 ? ", " : null} + {row.task.label} + + )), actions: ( <> {startedAtMs === null ? null : ( @@ -218,7 +235,11 @@ export function backgroundWorkNowItem(input: { Open )} - void input.onStop()}> + void input.onStop()} + > {input.stopping ? "Stopping..." : "Stop"} diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 3f8ef2a7f9d31..7e8825d3c033b 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -25,6 +25,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + presentPendingBackgroundWork, presentRunStop, threadRuntimeHasInterruptibleRun, } from "./threadExecution.ts"; @@ -328,6 +329,44 @@ describe("presentRunStop", () => { }); }); +describe("presentPendingBackgroundWork", () => { + const watch = "Watching pull request #404"; + it.each([ + { kind: "monitor", awaited: true, description: watch, title: watch, verb: null }, + { kind: "monitor", awaited: undefined, description: watch, title: watch, verb: null }, + { + kind: "command", + awaited: true, + description: "pnpm test", + title: "Waiting on command pnpm test", + verb: "Waiting on", + }, + { + kind: "command", + awaited: undefined, + description: "pnpm test", + title: "Running: pnpm test", + verb: "Running", + }, + ] as const)( + "titles a $kind with awaited $awaited", + ({ kind, awaited, description, title, verb }) => { + const presentation = presentPendingBackgroundWork([ + { taskId: "bg-1", description, kind, ...(awaited ? { awaited } : {}) }, + ]); + expect(presentation?.title).toBe(title); + expect(presentation?.subject).toMatchObject({ verb, item: { kind, label: description } }); + }, + ); + + it("leaves an unnamed task without a subject", () => { + expect(presentPendingBackgroundWork([{ taskId: "bg-1", kind: "command" }])).toMatchObject({ + title: "Running a command", + subject: null, + }); + }); +}); + describe("deriveRunlessWorkStartedAt", () => { const later = DateTime.makeUnsafe("2026-07-28T10:05:00.000Z"); const rootTurn = ( diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 52d07babfbb61..05a3451215adb 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -352,6 +352,11 @@ export interface PendingBackgroundWorkItem { export interface PendingBackgroundWorkPresentation { readonly title: string; + /** The one named task, for a line that styles its label apart from the verb. */ + readonly subject: { + readonly verb: "Waiting on" | "Running" | null; + readonly item: PendingBackgroundWorkItem; + } | null; readonly items: ReadonlyArray; readonly waiting: boolean; } @@ -392,14 +397,21 @@ export function presentPendingBackgroundWork( if (items.length === 1 && only !== undefined) { const noun = BACKGROUND_WORK_KINDS[only.kind].singular; const named = only.label !== noun; - const title = waiting - ? named - ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}` - : `Waiting on a ${noun}` - : named - ? `Running: ${only.label}` - : `Running a ${noun}`; - return { title, items, waiting }; + // A named monitor describes itself in a sentence, so it gets no verb. + const title = + named && only.kind === "monitor" + ? only.label + : waiting + ? named + ? `Waiting on ${noun} ${only.label}` + : `Waiting on a ${noun}` + : named + ? `Running: ${only.label}` + : `Running a ${noun}`; + const subject: PendingBackgroundWorkPresentation["subject"] = named + ? { verb: only.kind === "monitor" ? null : waiting ? "Waiting on" : "Running", item: only } + : null; + return { title, subject, items, waiting }; } const counts = new Map(); for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1); @@ -407,7 +419,12 @@ export function presentPendingBackgroundWork( const { singular, plural } = BACKGROUND_WORK_KINDS[kind]; return `${count} ${count === 1 ? singular : plural}`; }); - return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting }; + return { + title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, + subject: null, + items, + waiting, + }; } export interface ProviderGoalPresentation {