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) {...@@ -6859,7 +6859,7 @@ export default function ChatView(props: ChatViewProps) {
6859 // The stack renders items[0] front-most and tucks the rest behind hover, so6859 // The stack renders items[0] front-most and tucks the rest behind hover, so
6860 // ordering is priority: system banners, then the branch-mismatch notice,6860 // ordering is priority: system banners, then the branch-mismatch notice,
6861 // and the informational parked-thread banner last — it must never cover another.6861 // and the informational parked-thread banner last — it must never cover another.
6862 // Background work (subagent fleets, workflow runs, watch loops) can outlive6862 // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive
6863 // the turn; once it settles, the composer stop button is gone, so this6863 // the turn; once it settles, the composer stop button is gone, so this
6864 // banner is the only visible stop affordance. The interrupt path also6864 // banner is the only visible stop affordance. The interrupt path also
6865 // accepts a completed run while its provider still has background work.6865 // accepts a completed run while its provider still has background work.
...@@ -6907,9 +6907,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -6907,9 +6907,14 @@ export default function ChatView(props: ChatViewProps) {
6907 id: `background-work:${activeThread.id}`,6907 id: `background-work:${activeThread.id}`,
6908 variant: "default",6908 variant: "default",
6909 priority: "activity",6909 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.
6910 icon: (6912 icon: (
6911 <span6913 <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 )}
6913 aria-hidden="true"6918 aria-hidden="true"
6914 />6919 />
6915 ),6920 ),
packages/client-runtime/src/state/threadExecution.test.ts+28-2
...@@ -520,6 +520,7 @@ describe("presentPendingBackgroundWork", () => {...@@ -520,6 +520,7 @@ describe("presentPendingBackgroundWork", () => {
520 expect(presentation).toEqual({520 expect(presentation).toEqual({
521 title: "Waiting on subagent Luna Window Properties",521 title: "Waiting on subagent Luna Window Properties",
522 items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }],522 items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }],
523 waiting: true,
523 });524 });
524 });525 });
525526
...@@ -544,12 +545,37 @@ describe("presentPendingBackgroundWork", () => {...@@ -544,12 +545,37 @@ describe("presentPendingBackgroundWork", () => {
544 { taskId: "a", kind: "subagent", description: "Review src/math.ts" },545 { taskId: "a", kind: "subagent", description: "Review src/math.ts" },
545 ])?.title,546 ])?.title,
546 ).toBe("Waiting on subagent Review src/math.ts");547 ).toBe("Waiting on subagent Review src/math.ts");
547 expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe(548 expect(presentPendingBackgroundWork([{ taskId: "a", kind: "monitor" }])?.title).toBe(
548 "Waiting on a command",549 "Waiting on a monitor",
549 );550 );
550 expect(presentPendingBackgroundWork([])).toBeNull();551 expect(presentPendingBackgroundWork([])).toBeNull();
551 });552 });
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
553 it("groups work by kind, subagents first, and keeps each name", () => {579 it("groups work by kind, subagents first, and keeps each name", () => {
554 const presentation = presentPendingBackgroundWork([580 const presentation = presentPendingBackgroundWork([
555 { taskId: "cmd", kind: "command", description: "npm test" },581 { taskId: "cmd", kind: "command", description: "npm test" },
packages/client-runtime/src/state/threadExecution.ts+25-8
...@@ -16,7 +16,10 @@ import {...@@ -16,7 +16,10 @@ import {
16 orchestrationV2RunWorkStartedAt,16 orchestrationV2RunWorkStartedAt,
17 type ThreadId,17 type ThreadId,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";19import {
20 backgroundWorkHoldsCompletion,
21 derivePendingBackgroundWork,
22} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
20import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";23import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
21import { formatDuration } from "@t3tools/shared/orchestrationTiming";24import { formatDuration } from "@t3tools/shared/orchestrationTiming";
22import * as DateTime from "effect/DateTime";25import * as DateTime from "effect/DateTime";
...@@ -295,9 +298,17 @@ export interface PendingBackgroundWorkItem {...@@ -295,9 +298,17 @@ export interface PendingBackgroundWorkItem {
295}298}
296299
297export interface PendingBackgroundWorkPresentation {300export 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 */
299 readonly title: string;305 readonly title: string;
300 readonly items: ReadonlyArray<PendingBackgroundWorkItem>;306 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;
301}312}
302313
303function joinWithAnd(parts: ReadonlyArray<string>): string {314function joinWithAnd(parts: ReadonlyArray<string>): string {
...@@ -305,11 +316,12 @@ function joinWithAnd(parts: ReadonlyArray<string>): string {...@@ -305,11 +316,12 @@ function joinWithAnd(parts: ReadonlyArray<string>): string {
305 return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;316 return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
306}317}
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. */
309export function presentPendingBackgroundWork(320export function presentPendingBackgroundWork(
310 tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,321 tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
311): PendingBackgroundWorkPresentation | null {322): PendingBackgroundWorkPresentation | null {
312 if (tasks.length === 0) return null;323 if (tasks.length === 0) return null;
324 const waiting = backgroundWorkHoldsCompletion(tasks);
313 const items = tasks325 const items = tasks
314 .map((task): PendingBackgroundWorkItem => {326 .map((task): PendingBackgroundWorkItem => {
315 const description = task.description?.trim();327 const description = task.description?.trim();
...@@ -335,10 +347,15 @@ export function presentPendingBackgroundWork(...@@ -335,10 +347,15 @@ export function presentPendingBackgroundWork(
335 const [only] = items;347 const [only] = items;
336 if (items.length === 1 && only !== undefined) {348 if (items.length === 1 && only !== undefined) {
337 const noun = BACKGROUND_WORK_KINDS[only.kind].singular;349 const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
338 return {350 const named = only.label !== noun;
339 title: only.label === noun ? `Waiting on a ${noun}` : `Waiting on ${noun} ${only.label}`,351 const title = waiting
340 items,352 ? named
341 };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 };
342 }359 }
343 const counts = new Map<BackgroundWorkKind, number>();360 const counts = new Map<BackgroundWorkKind, number>();
344 for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);361 for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
...@@ -346,7 +363,7 @@ export function presentPendingBackgroundWork(...@@ -346,7 +363,7 @@ export function presentPendingBackgroundWork(
346 const { singular, plural } = BACKGROUND_WORK_KINDS[kind];363 const { singular, plural } = BACKGROUND_WORK_KINDS[kind];
347 return `${count} ${count === 1 ? singular : plural}`;364 return `${count} ${count === 1 ? singular : plural}`;
348 });365 });
349 return { title: `Waiting on ${joinWithAnd(groups)}`, items };366 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
350}367}
351368
352/** The thread a notification row opens: that of the one subagent or delegated task it reports. */369/** The thread a notification row opens: that of the one subagent or delegated task it reports. */