diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 8e105a49f37f49ee18f358e88c03716898f62206..fe72ec446d348c14dd6bfb4e4ae65e90c778be10 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -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.
@@ -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: (
),
diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts
index 92745606d1eac1e7263423b3c1813c66f5225387..6ee64d2eaf66e227843e6fcf28080b498af6d691 100644
--- a/packages/client-runtime/src/state/threadExecution.test.ts
+++ b/packages/client-runtime/src/state/threadExecution.test.ts
@@ -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,
});
});
@@ -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" },
diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts
index 962e508670eb82975d275b07d3a8849f7f4bd1d4..51321d72267759ff36a41c5e9a8b413b8d72243e 100644
--- a/packages/client-runtime/src/state/threadExecution.ts
+++ b/packages/client-runtime/src/state/threadExecution.ts
@@ -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";
@@ -295,9 +298,17 @@ 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;
+ /**
+ * 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 {
@@ -305,11 +316,12 @@ function joinWithAnd(parts: ReadonlyArray): string {
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,
): PendingBackgroundWorkPresentation | null {
if (tasks.length === 0) return null;
+ const waiting = backgroundWorkHoldsCompletion(tasks);
const items = tasks
.map((task): PendingBackgroundWorkItem => {
const description = task.description?.trim();
@@ -335,10 +347,15 @@ 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();
for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
@@ -346,7 +363,7 @@ export function presentPendingBackgroundWork(
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. */