From 89d964ef7144a225cf6fb774ae03049194e6862b Mon Sep 17 00:00:00 2001 From: Ali Alwan <11404482+Ali-Al1@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:38:56 +0000 Subject: [PATCH] fix(web): a PR watch keeps a thread Waiting, not Done or Continue - Sidebar status reads the same awaited-background-work hold as the status pill, so a completed, unread thread with a live PR watch shows Waiting. - Direct-link thread shells carry the derived pending background roster, so the field agrees with the runtime that already parks on it. - Continue is not offered for an interrupted run while a hold is active; interrupted runs with no hold still resume, as do held queued runs. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.logic.ts | 25 +++++- apps/web/src/components/ChatView.tsx | 4 +- .../components/ChatView.waitingHold.test.ts | 90 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 24 +++++ apps/web/src/components/Sidebar.logic.ts | 9 +- 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/ChatView.waitingHold.test.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2b8424f9efe0a..a421a0a6c20f2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,11 +1,15 @@ import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; import { deriveLatestThreadRun, + deriveResumableRunId, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { COMPACT_COMMAND_PATTERN } from "@t3tools/client-runtime/state/context-window"; -import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + backgroundWorkHoldsCompletion, + derivePendingBackgroundWork, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, usageLimitRunPresentedAsLatest, @@ -1446,6 +1450,8 @@ export function presentThreadShellFromProjection( )?.goal ?? null, itemCount: projection.turnItems.length, visibleItemCount: projection.visibleTurnItems.length, + // The structure carries no roster; derive it so it agrees with the runtime below. + pendingBackgroundTasks: deriveThreadPendingBackgroundTasks(projection), updatedAt, }), latestRun, @@ -1453,6 +1459,23 @@ export function presentThreadShellFromProjection( }; } +/** + * The run Continue resumes. An interrupted run is not offered while a pull request watch or + * other awaited background work holds the thread: it is waiting, not stopped. + */ +export function deriveContinuableRunId( + projection: ThreadStructure, + runtime: Parameters[1], +): RunId | null { + const runId = deriveResumableRunId(projection, runtime); + if (runId === null) return null; + const run = projection.runs.find((candidate) => candidate.id === runId); + return run?.status === "interrupted" && + backgroundWorkHoldsCompletion(deriveThreadPendingBackgroundTasks(projection)) + ? null + : runId; +} + /** Background work the provider still runs after the latest run ended. */ export function deriveThreadPendingBackgroundTasks(projection: ThreadStructure | null) { if (projection === null) return []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92149651480db..5b5461c66b491 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9,6 +9,7 @@ import { configuredHostedAppUrl } from "../hostedPairing"; import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; import { deriveAvailableRevertTurnCounts, + deriveContinuableRunId, deriveThreadPendingBackgroundTasks, presentThreadShellFromProjection, } from "./ChatView.logic"; @@ -76,7 +77,6 @@ import { deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveReportedModelSelection, - deriveResumableRunId, formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, @@ -813,7 +813,7 @@ export default function ChatView(props: ChatViewProps) { const resumableRunId = !isServerThread || serverProjection === null ? null - : deriveResumableRunId(serverProjection, serverRuntime); + : deriveContinuableRunId(serverProjection, serverRuntime); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId diff --git a/apps/web/src/components/ChatView.waitingHold.test.ts b/apps/web/src/components/ChatView.waitingHold.test.ts new file mode 100644 index 0000000000000..10e477c8b4d8b --- /dev/null +++ b/apps/web/src/components/ChatView.waitingHold.test.ts @@ -0,0 +1,90 @@ +import { + EnvironmentId, + MessageId, + RunId, + type OrchestrationV2RunStatus, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveContinuableRunId, presentThreadShellFromProjection } from "./ChatView.logic"; +import { makeThreadProjectionFixture } from "../test-fixtures"; + +const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + +function run(status: OrchestrationV2RunStatus, base: OrchestrationV2ThreadProjection) { + return { + id: RunId.make("run-1"), + threadId: base.thread.id, + ordinal: 1, + providerInstanceId: base.thread.providerInstanceId, + modelSelection: base.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message-run-1"), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }; +} + +const watchedPullRequest = { + host: "github.com", + repository: "acme/app", + number: 1, + url: "https://github.com/acme/app/pull/1", + source: "agent" as const, + snapshot: null, + watch: { + startedAt: "2026-01-01T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: "2026-01-01T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }, +}; + +function projection(status: OrchestrationV2RunStatus, watched: boolean) { + const base = makeThreadProjectionFixture(); + return { + ...base, + thread: { ...base.thread, ...(watched ? { pullRequests: [watchedPullRequest] } : {}) }, + runs: [run(status, base)], + } as unknown as Parameters[2]; +} + +describe("pull request watch holds", () => { + it("gives a direct-link shell the watch roster its runtime parks on", () => { + const shell = presentThreadShellFromProjection( + EnvironmentId.make("environment-test"), + makeThreadProjectionFixture().thread.id, + projection("completed", true), + { updatedAt: now, lastVisitedAt: null }, + ); + expect(shell.runtime?.status).toBe("idle"); + expect(shell.pendingBackgroundTasks).toEqual([ + expect.objectContaining({ + taskId: "pull-request-watch:github.com/acme/app#1", + kind: "monitor", + awaited: true, + }), + ]); + }); + + it("offers Continue for an interrupted run only when nothing holds the thread", () => { + const held = projection("interrupted", true); + expect(deriveContinuableRunId(held, null)).toBeNull(); + + const unheld = projection("interrupted", false); + expect(deriveContinuableRunId(unheld, null)).toBe(RunId.make("run-1")); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9e7489a0ffca1..19a6360e74c77 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -246,6 +246,30 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("approval"); }); + it("keeps a completed thread Waiting, not Done, while a pull request watch holds it", () => { + const completed = { ...runtime, status: "completed" as const }; + const watch = { + taskId: "pull-request-watch:github.com/acme/app#1", + description: "Watching pull request #1", + kind: "monitor" as const, + awaited: true as const, + }; + expect( + resolveSidebarThreadStatus({ ...idle, runtime: completed, pendingBackgroundTasks: [watch] }), + ).toBe("waiting"); + expect(resolveSidebarThreadStatus({ ...idle, runtime: completed })).toBe("ready"); + // Work the agent does not wait on, such as a dev server, does not hold it. + expect( + resolveSidebarThreadStatus({ + ...idle, + runtime: completed, + pendingBackgroundTasks: [ + { taskId: "dev-server", description: "Dev server", kind: "command" as const }, + ], + }), + ).toBe("ready"); + }); + it("shows a waiting guest message as a Request, below the agent's own requests", () => { expect(resolveSidebarThreadStatus({ ...idle, pendingGuestMessageCount: 1, runtime })).toBe( "request", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e2c6a60fc33d5..a5cb141412641 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -856,7 +856,9 @@ type SidebarThreadStatusInput = Pick< | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" ->; +> & { + pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined; +}; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { @@ -887,6 +889,11 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if (thread.runtime?.status === "failed") { return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed"; } + // Same hold the status pill reads: a PR watch or other awaited background + // work keeps the thread waiting even when its runtime was not parked. + if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) { + return "waiting"; + } return "ready"; }