Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6859,7 +6859,7 @@ export default function ChatView(props: ChatViewProps) {
// The stack renders items[0] front-most and tucks the rest behind hover, so
// ordering is priority: system banners, then the branch-mismatch notice,
// and the informational parked-thread banner last — it must never cover another.
// Background work (subagent fleets, workflow runs, watch loops) can outlive
// Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive
// the turn; once it settles, the composer stop button is gone, so this
// banner is the only visible stop affordance. The interrupt path also
// accepts a completed run while its provider still has background work.
Expand Down Expand Up @@ -6907,9 +6907,14 @@ export default function ChatView(props: ChatViewProps) {
id: `background-work:${activeThread.id}`,
variant: "default",
priority: "activity",
// A dev server can run for hours after the agent is done, so only work
// that will wake the agent pulses.
icon: (
<span
className="size-1.5 animate-status-pulse rounded-full bg-foreground"
className={cn(
"size-1.5 rounded-full bg-foreground",
presentation.waiting && "animate-status-pulse",
)}
aria-hidden="true"
/>
),
Expand Down
30 changes: 28 additions & 2 deletions packages/client-runtime/src/state/threadExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ describe("presentPendingBackgroundWork", () => {
expect(presentation).toEqual({
title: "Waiting on subagent Luna Window Properties",
items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }],
waiting: true,
});
});

Expand All @@ -544,12 +545,37 @@ describe("presentPendingBackgroundWork", () => {
{ taskId: "a", kind: "subagent", description: "Review src/math.ts" },
])?.title,
).toBe("Waiting on subagent Review src/math.ts");
expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe(
"Waiting on a command",
expect(presentPendingBackgroundWork([{ taskId: "a", kind: "monitor" }])?.title).toBe(
"Waiting on a monitor",
);
expect(presentPendingBackgroundWork([])).toBeNull();
});

// A command left running, such as a dev server, does not wake the agent.
it("says only commands are running, not waited on", () => {
expect(
presentPendingBackgroundWork([
{ taskId: "dev", kind: "command", description: "Start the shared dev server" },
]),
).toMatchObject({ title: "Running: Start the shared dev server", waiting: false });
expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])).toMatchObject({
title: "Running a command",
waiting: false,
});
expect(
presentPendingBackgroundWork([
{ taskId: "a", kind: "command", description: "vp run dev" },
{ taskId: "b", kind: "command", description: "tailscale serve" },
]),
).toMatchObject({ title: "Running 2 commands", waiting: false });
expect(
presentPendingBackgroundWork([
{ taskId: "a", kind: "command", description: "vp run dev" },
{ taskId: "b", kind: "monitor", description: "Watch PR checks" },
]),
).toMatchObject({ title: "Waiting on 1 command and 1 monitor", waiting: true });
});

it("groups work by kind, subagents first, and keeps each name", () => {
const presentation = presentPendingBackgroundWork([
{ taskId: "cmd", kind: "command", description: "npm test" },
Expand Down
33 changes: 25 additions & 8 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
orchestrationV2RunWorkStartedAt,
type ThreadId,
} from "@t3tools/contracts";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -295,21 +298,30 @@ export interface PendingBackgroundWorkItem {
}

export interface PendingBackgroundWorkPresentation {
/** "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command". */
/**
* "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command",
* or "Running: Start the dev server" when only commands remain.
*/
readonly title: string;
readonly items: ReadonlyArray<PendingBackgroundWorkItem>;
/**
* True when the work will wake the agent (subagents, monitors). False when
* only commands remain, such as a dev server: the agent is done.
*/
readonly waiting: boolean;
}

function joinWithAnd(parts: ReadonlyArray<string>): string {
if (parts.length <= 1) return parts.join("");
return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
}

/** Names what a settled thread is still waiting on, grouped by kind, for the composer strip. */
/** Names what a settled thread still runs, grouped by kind, for the composer strip. */
export function presentPendingBackgroundWork(
tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
): PendingBackgroundWorkPresentation | null {
if (tasks.length === 0) return null;
const waiting = backgroundWorkHoldsCompletion(tasks);
const items = tasks
.map((task): PendingBackgroundWorkItem => {
const description = task.description?.trim();
Expand All @@ -335,18 +347,23 @@ export function presentPendingBackgroundWork(
const [only] = items;
if (items.length === 1 && only !== undefined) {
const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
return {
title: only.label === noun ? `Waiting on a ${noun}` : `Waiting on ${noun} ${only.label}`,
items,
};
const named = only.label !== noun;
const title = waiting
? named
? `Waiting on ${noun} ${only.label}`
: `Waiting on a ${noun}`
: named
? `Running: ${only.label}`
: `Running a ${noun}`;
return { title, items, waiting };
}
const counts = new Map<BackgroundWorkKind, number>();
for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
const groups = Array.from(counts, ([kind, count]) => {
const { singular, plural } = BACKGROUND_WORK_KINDS[kind];
return `${count} ${count === 1 ? singular : plural}`;
});
return { title: `Waiting on ${joinWithAnd(groups)}`, items };
return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
}

/** The thread a notification row opens: that of the one subagent or delegated task it reports. */
Expand Down
Loading