| author | |
| committer | |
| log | a5b34b25378fbbf90ce36ee101a11528ccf11c7e |
| tree | 6d273b1b36097c4315657da883fb8672c4d73449 |
| parent | 56914128c1ff9dcf6585c380b7b25845d6ca5f98 |
| signature | Signed by PGP key B5690EEEBB952194 |
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, so | 6859 | // 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 outlive | 6862 | // 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 this | 6863 | // the turn; once it settles, the composer stop button is gone, so this |
| 6864 | // banner is the only visible stop affordance. The interrupt path also | 6864 | // 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 | <span | 6913 | <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 | }); |
| 525 | 526 | ||
| ... | @@ -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 | }); |
| 552 | 553 | ||
| 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"; |
| 19 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 19 | import { |
| 20 | backgroundWorkHoldsCompletion, | ||
| 21 | derivePendingBackgroundWork, | ||
| 22 | } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 20 | import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model"; | 23 | import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model"; |
| 21 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 24 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 22 | import * as DateTime from "effect/DateTime"; | 25 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -295,9 +298,17 @@ export interface PendingBackgroundWorkItem { | ... | @@ -295,9 +298,17 @@ export interface PendingBackgroundWorkItem { |
| 295 | } | 298 | } |
| 296 | 299 | ||
| 297 | export interface PendingBackgroundWorkPresentation { | 300 | export 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 | } |
| 302 | 313 | ||
| 303 | function joinWithAnd(parts: ReadonlyArray<string>): string { | 314 | function 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 | } |
| 307 | 318 | ||
| 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. */ |
| 309 | export function presentPendingBackgroundWork( | 320 | export 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 = tasks | 325 | 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 | } |
| 351 | 368 | ||
| 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. */ |