authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 02:07:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:07:12-07:00
loga5b34b25378fbbf90ce36ee101a11528ccf11c7e
tree6d273b1b36097c4315657da883fb8672c4d73449
parent56914128c1ff9dcf6585c380b7b25845d6ca5f98
signature Signed by PGP key B5690EEEBB952194

fix(clients): a dev server left running no longer says the thread is waiting (#15114)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 60 insertions(+), 12 deletions(-)

apps/web/src/components/ChatView.tsx+7-2
......@@ -6859,7 +6859,7 @@ export default function ChatView(props: ChatViewProps) {
68596859 // The stack renders items[0] front-most and tucks the rest behind hover, so
68606860 // ordering is priority: system banners, then the branch-mismatch notice,
68616861 // and the informational parked-thread banner last — it must never cover another.
6862 // Background work (subagent fleets, workflow runs, watch loops) can outlive
6862 // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive
68636863 // the turn; once it settles, the composer stop button is gone, so this
68646864 // banner is the only visible stop affordance. The interrupt path also
68656865 // accepts a completed run while its provider still has background work.
......@@ -6907,9 +6907,14 @@ export default function ChatView(props: ChatViewProps) {
69076907 id: `background-work:${activeThread.id}`,
69086908 variant: "default",
69096909 priority: "activity",
6910 // A dev server can run for hours after the agent is done, so only work
6911 // that will wake the agent pulses.
69106912 icon: (
69116913 <span
6912 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
6914 className={cn(
6915 "size-1.5 rounded-full bg-foreground",
6916 presentation.waiting && "animate-status-pulse",
6917 )}
69136918 aria-hidden="true"
69146919 />
69156920 ),
packages/client-runtime/src/state/threadExecution.test.ts+28-2
......@@ -520,6 +520,7 @@ describe("presentPendingBackgroundWork", () => {
520520 expect(presentation).toEqual({
521521 title: "Waiting on subagent Luna Window Properties",
522522 items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }],
523 waiting: true,
523524 });
524525 });
525526
......@@ -544,12 +545,37 @@ describe("presentPendingBackgroundWork", () => {
544545 { taskId: "a", kind: "subagent", description: "Review src/math.ts" },
545546 ])?.title,
546547 ).toBe("Waiting on subagent Review src/math.ts");
547 expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe(
548 "Waiting on a command",
548 expect(presentPendingBackgroundWork([{ taskId: "a", kind: "monitor" }])?.title).toBe(
549 "Waiting on a monitor",
549550 );
550551 expect(presentPendingBackgroundWork([])).toBeNull();
551552 });
552553
554 // A command left running, such as a dev server, does not wake the agent.
555 it("says only commands are running, not waited on", () => {
556 expect(
557 presentPendingBackgroundWork([
558 { taskId: "dev", kind: "command", description: "Start the shared dev server" },
559 ]),
560 ).toMatchObject({ title: "Running: Start the shared dev server", waiting: false });
561 expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])).toMatchObject({
562 title: "Running a command",
563 waiting: false,
564 });
565 expect(
566 presentPendingBackgroundWork([
567 { taskId: "a", kind: "command", description: "vp run dev" },
568 { taskId: "b", kind: "command", description: "tailscale serve" },
569 ]),
570 ).toMatchObject({ title: "Running 2 commands", waiting: false });
571 expect(
572 presentPendingBackgroundWork([
573 { taskId: "a", kind: "command", description: "vp run dev" },
574 { taskId: "b", kind: "monitor", description: "Watch PR checks" },
575 ]),
576 ).toMatchObject({ title: "Waiting on 1 command and 1 monitor", waiting: true });
577 });
578
553579 it("groups work by kind, subagents first, and keeps each name", () => {
554580 const presentation = presentPendingBackgroundWork([
555581 { taskId: "cmd", kind: "command", description: "npm test" },
packages/client-runtime/src/state/threadExecution.ts+25-8
......@@ -16,7 +16,10 @@ import {
1616 orchestrationV2RunWorkStartedAt,
1717 type ThreadId,
1818} from "@t3tools/contracts";
19import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
19import {
20 backgroundWorkHoldsCompletion,
21 derivePendingBackgroundWork,
22} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
2023import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
2124import { formatDuration } from "@t3tools/shared/orchestrationTiming";
2225import * as DateTime from "effect/DateTime";
......@@ -295,9 +298,17 @@ export interface PendingBackgroundWorkItem {
295298}
296299
297300export interface PendingBackgroundWorkPresentation {
298 /** "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command". */
301 /**
302 * "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command",
303 * or "Running: Start the dev server" when only commands remain.
304 */
299305 readonly title: string;
300306 readonly items: ReadonlyArray<PendingBackgroundWorkItem>;
307 /**
308 * True when the work will wake the agent (subagents, monitors). False when
309 * only commands remain, such as a dev server: the agent is done.
310 */
311 readonly waiting: boolean;
301312}
302313
303314function joinWithAnd(parts: ReadonlyArray<string>): string {
......@@ -305,11 +316,12 @@ function joinWithAnd(parts: ReadonlyArray<string>): string {
305316 return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
306317}
307318
308/** Names what a settled thread is still waiting on, grouped by kind, for the composer strip. */
319/** Names what a settled thread still runs, grouped by kind, for the composer strip. */
309320export function presentPendingBackgroundWork(
310321 tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
311322): PendingBackgroundWorkPresentation | null {
312323 if (tasks.length === 0) return null;
324 const waiting = backgroundWorkHoldsCompletion(tasks);
313325 const items = tasks
314326 .map((task): PendingBackgroundWorkItem => {
315327 const description = task.description?.trim();
......@@ -335,10 +347,15 @@ export function presentPendingBackgroundWork(
335347 const [only] = items;
336348 if (items.length === 1 && only !== undefined) {
337349 const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
338 return {
339 title: only.label === noun ? `Waiting on a ${noun}` : `Waiting on ${noun} ${only.label}`,
340 items,
341 };
350 const named = only.label !== noun;
351 const title = waiting
352 ? named
353 ? `Waiting on ${noun} ${only.label}`
354 : `Waiting on a ${noun}`
355 : named
356 ? `Running: ${only.label}`
357 : `Running a ${noun}`;
358 return { title, items, waiting };
342359 }
343360 const counts = new Map<BackgroundWorkKind, number>();
344361 for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
......@@ -346,7 +363,7 @@ export function presentPendingBackgroundWork(
346363 const { singular, plural } = BACKGROUND_WORK_KINDS[kind];
347364 return `${count} ${count === 1 ? singular : plural}`;
348365 });
349 return { title: `Waiting on ${joinWithAnd(groups)}`, items };
366 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
350367}
351368
352369/** The thread a notification row opens: that of the one subagent or delegated task it reports. */