authorgravatar for 56691628+Mnigos@users.noreply.github.comIgor Makowski <56691628+Mnigos@users.noreply.github.com> 2026-10-03 11:12:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:12:28-07:00
logf68e24fb419c985269cd0acb841e203af1419c43
tree0b7e69fcdbbe758e1b8e7619fe9ca8e92f5866ef
parenta5b34b25378fbbf90ce36ee101a11528ccf11c7e
signature Signed by PGP key B5690EEEBB952194

fix(web): a thread that left a shell running shows its unseen completion (#14910)

Co-authored-by: Theo Browne <me@t3.gg>

8 files changed, 204 insertions(+), 36 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+27-4
......@@ -1,3 +1,5 @@
1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
13import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
24import {
35 createPendingThreadOrder,
......@@ -23,7 +25,7 @@ import {
2325import { describe, expect, it, vi } from "vite-plus/test";
2426
2527import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { makeThreadShellFixture } from "../../test-fixtures";
28import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
2729import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
2830import {
2931 buildThreadListV2Items,
......@@ -36,6 +38,7 @@ import {
3638 resolveThreadListV2SwipeActions,
3739 sortThreadsForListV2,
3840 threadListV2ListItemsAreEqual,
41 threadHasUnseenCompletion,
3942 type ThreadListV2ListItem,
4043} from "./threadListV2";
4144
......@@ -160,9 +163,7 @@ describe("resolveThreadListV2Status", () => {
160163 makeThread({
161164 id: ThreadId.make("t"),
162165 title: "t",
163 pendingBackgroundTasks: [
164 { taskId: "bg-1", description: "Run Codex review", kind: "command" },
165 ],
166 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
166167 runtime: {
167168 status: "idle",
168169 activeRunId: null,
......@@ -176,6 +177,28 @@ describe("resolveThreadListV2Status", () => {
176177 ).toBe("waiting");
177178 });
178179
180 it.each([
181 { kind: "command", status: "ready" },
182 { kind: "monitor", status: "waiting" },
183 ] as const)(
184 "presents an unseen completion with a $kind roster as $status",
185 ({ kind, status }) => {
186 const thread = presentThreadShell(
187 environmentId,
188 makeRawThreadShell({
189 latestRunId: RunId.make("run-background-completion"),
190 status: "completed",
191 latestRunCompletedAt: DateTime.makeUnsafe(NOW),
192 lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"),
193 pendingBackgroundTasks: [{ taskId: "background-work", kind }],
194 }),
195 );
196
197 expect(resolveThreadListV2Status(thread)).toBe(status);
198 expect(threadHasUnseenCompletion(thread)).toBe(true);
199 },
200 );
201
179202 it("resolves ready for quiescent threads", () => {
180203 expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
181204 "ready",
apps/mobile/src/features/threads/threadListV2.ts+2-1
......@@ -62,7 +62,8 @@ export function resolveThreadListV2ProviderDrivers(
6262 * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
6363 * parked on open background tasks, grey like working rather than a false Done.
6464 * The orchestrator v2 presentation bridge parks runtime at idle when the
65 * post-settlement background roster is nonempty.
65 * post-settlement background roster holds the run's completion (subagents,
66 * monitors); commands left running, such as a dev server, read as ready.
6667 */
6768export type ThreadListV2Status =
6869 | "approval"
apps/web/src/components/Sidebar.logic.test.ts+37-2
......@@ -1,3 +1,5 @@
1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
13import { deriveActiveWorkStartedAt } from "../session-logic.ts";
24import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
35import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
......@@ -1254,7 +1256,7 @@ describe("resolveThreadStatusPill", () => {
12541256 resolveThreadStatusPill({
12551257 thread: {
12561258 ...baseThread,
1257 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],
1259 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
12581260 runtime: {
12591261 ...baseThread.runtime,
12601262 status: "idle",
......@@ -2053,6 +2055,39 @@ describe("navigation after parking a thread", () => {
20532055 );
20542056});
20552057
2058describe("unseen completion with background work", () => {
2059 it.each([
2060 { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" },
2061 { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" },
2062 ] as const)("presents a completed thread with a $kind roster", (expected) => {
2063 const thread = presentThreadShell(localEnvironmentId, {
2064 ...makeThreadFixture().source,
2065 latestRunId: RunId.make("run-background-completion"),
2066 status: "completed",
2067 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
2068 lastVisitedAt: DateTime.makeUnsafe("2026-06-20T00:59:00.000Z"),
2069 pendingBackgroundTasks: [{ taskId: "background-work", kind: expected.kind }],
2070 });
2071 const status = resolveSidebarThreadStatus(thread);
2072 const isUnread = hasUnseenCompletion(thread);
2073
2074 expect(isUnread).toBe(true);
2075 expect(status).toBe(expected.status);
2076 expect(resolveSidebarV2TopStatus({ status, isUnread, isWoke: false })).toBe(expected.topStatus);
2077 expect(
2078 shouldRecedeSidebarThread({
2079 status,
2080 isUnread,
2081 isWoke: false,
2082 isActive: false,
2083 isSelected: false,
2084 }),
2085 ).toBe(expected.receded);
2086 expect(isSidebarThreadWorking(thread)).toBe(expected.receded);
2087 expect(resolveThreadStatusPill({ thread })).toMatchObject({ label: expected.pill });
2088 });
2089});
2090
20562091describe("Working shelf (beta)", () => {
20572092 const runtime = {
20582093 status: "running" as const,
......@@ -2062,7 +2097,7 @@ describe("Working shelf (beta)", () => {
20622097 lastError: null,
20632098 updatedAt: "2026-03-09T10:00:00.000Z",
20642099 };
2065 const backgroundTask = { taskId: "bg-1", description: "sleep 20", kind: "command" as const };
2100 const backgroundTask = { taskId: "bg-1", description: "Watch build", kind: "monitor" as const };
20662101 const idle = {
20672102 hasActionableProposedPlan: false,
20682103 hasPendingApprovals: false,
apps/web/src/components/Sidebar.logic.ts+8-6
......@@ -1,4 +1,5 @@
11import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
2import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
23import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
34import * as React from "react";
45import {
......@@ -910,9 +911,10 @@ export function resolveThreadRowClassName(input: {
910911// (approval), "in motion" (working), and "broken" (failed). Ready is the
911912// unlabeled resting state — the agent stopped and is waiting on the user,
912913// whether it finished, asked a question, or proposed a plan. Waiting
913// (runtime status "idle") is the agent stopped with background tasks still
914// open: not the user's turn yet, so it renders grey like working, not as a
915// false Done.
914// (runtime status "idle") is the agent stopped with background work that will
915// wake it (subagents, monitors): not the user's turn yet, so it renders grey
916// like working, not as a false Done. Commands it left running, such as a dev
917// server, do not hold the thread; it reads as ready.
916918// Unread completion is tracked separately: it describes whether a ready
917919// thread needs attention, not what the thread is currently doing.
918920export type SidebarThreadStatus =
......@@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
10071009}
10081010
10091011/** Working beta: threads busy with work that does not need the user fold into
1010 the Working shelf: a running run, or one stopped with background tasks
1011 still open. Approvals, questions, plan prompts, and failures stay in the
1012 the Working shelf: a running run, or one stopped with background work
1013 that will wake it. Approvals, questions, plan prompts, and failures stay in the
10121014 inbox. */
10131015export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
10141016 const status = resolveSidebarThreadStatus(thread);
......@@ -1209,7 +1211,7 @@ export function resolveThreadStatusPill(input: {
12091211 };
12101212 }
12111213
1212 if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) {
1214 if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) {
12131215 return {
12141216 label: "Waiting",
12151217 colorClass: "text-sidebar-muted-foreground",
apps/web/src/components/ThreadNotificationCoordinator.tsx+4-6
......@@ -2,7 +2,6 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models";
22import { useAtomValue } from "@effect/atom-react";
33import { useNavigate, useParams } from "@tanstack/react-router";
44import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
65import * as Option from "effect/Option";
76import {
87 CircleAlertIcon,
......@@ -126,12 +125,11 @@ function EnvironmentNotifications({
126125 ? `${thread.latestRun?.runId ?? ""}:${status}`
127126 : null;
128127 const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");
129 // Waiting only on commands (a dev server) is done; subagents and monitors wake the agent.
130 const settled =
131 status === "ready" ||
132 (status === "waiting" && !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks));
128 // Commands left running (a dev server) read as ready; subagents and monitors wait.
133129 const completion =
134 settled && thread.latestRun?.status === "completed" && Number.isFinite(completedAt)
130 status === "ready" &&
131 thread.latestRun?.status === "completed" &&
132 Number.isFinite(completedAt)
135133 ? completedAt
136134 : (prior?.completion ?? null);
137135 next.set(thread.id, { attention, completion });
packages/client-runtime/src/state/entities.test.ts+112-11
......@@ -2,8 +2,10 @@ import {
22 EnvironmentId,
33 MessageId,
44 NodeId,
5 ProviderDriverKind,
56 ProviderInstanceId,
67 ProviderSessionId,
8 ProviderThreadId,
79 RunId,
810 RuntimeRequestId,
911 TurnItemId,
......@@ -140,7 +142,7 @@ describe("V2 client presentation", () => {
140142 latestRunId: runId,
141143 activeRunId: null,
142144 status: "completed",
143 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],
145 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
144146 });
145147
146148 expect(shell.latestRun).toMatchObject({ runId, status: "completed" });
......@@ -149,10 +151,50 @@ describe("V2 client presentation", () => {
149151 activeRunId: null,
150152 });
151153 expect(shell.pendingBackgroundTasks).toEqual([
152 { taskId: "bg-1", description: "sleep 20", kind: "command" },
154 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
153155 ]);
154156 });
155157
158 it.each([
159 { kinds: ["command"], expected: "completed" },
160 { kinds: ["command", "subagent"], expected: "idle" },
161 { kinds: ["background_task"], expected: "idle" },
162 ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => {
163 const pendingBackgroundTasks = kinds.map((kind, index) => ({
164 taskId: `bg-${index}`,
165 kind,
166 }));
167 const shell = presentThreadShell(environmentId, {
168 ...v2ThreadShell,
169 latestRunId: RunId.make("run-completed"),
170 activeRunId: null,
171 status: "completed",
172 pendingBackgroundTasks,
173 });
174
175 expect(shell.latestRun?.status).toBe("completed");
176 expect(shell.runtime).toMatchObject({ status: expected, activeRunId: null });
177 expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks);
178 });
179
180 it.each(["running", "waiting"] as const)(
181 "preserves shell %s while only commands remain in the roster",
182 (status) => {
183 const runId = RunId.make("run-command");
184 const shell = presentThreadShell(environmentId, {
185 ...v2ThreadShell,
186 latestRunId: runId,
187 activeRunId: runId,
188 status,
189 pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }],
190 });
191
192 expect(shell.latestRun?.status).toBe(status);
193 expect(shell.runtime).toMatchObject({ status, activeRunId: runId });
194 expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]);
195 },
196 );
197
156198 it("keeps a failed latest run failed while background tasks are still pending", () => {
157199 const shell = presentThreadShell(environmentId, {
158200 ...v2ThreadShell,
......@@ -160,7 +202,7 @@ describe("V2 client presentation", () => {
160202 activeRunId: null,
161203 status: "failed",
162204 lastError: "Provider turn failed",
163 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],
205 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
164206 });
165207
166208 // Sidebar and mobile list read runtime "idle" as Waiting before failure.
......@@ -286,7 +328,7 @@ describe("V2 client presentation", () => {
286328 // Stale: server already projected a post-settlement roster, but shell
287329 // status still says running (packaged orchestrator-v2 bug).
288330 status: "running",
289 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],
331 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
290332 });
291333
292334 expect(shell.latestRun).toMatchObject({ runId, status: "running" });
......@@ -295,7 +337,7 @@ describe("V2 client presentation", () => {
295337 activeRunId: runId,
296338 });
297339 expect(shell.pendingBackgroundTasks).toEqual([
298 { taskId: "bg-1", description: "sleep 20", kind: "command" },
340 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
299341 ]);
300342 });
301343
......@@ -307,7 +349,7 @@ describe("V2 client presentation", () => {
307349 activeRunId: runId,
308350 // Stale: checkpoint-oriented waiting masks post-settlement background work.
309351 status: "waiting",
310 pendingBackgroundTasks: [{ taskId: "bg-2", description: "background bash", kind: "command" }],
352 pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }],
311353 });
312354
313355 expect(shell.latestRun).toMatchObject({ runId, status: "waiting" });
......@@ -316,7 +358,7 @@ describe("V2 client presentation", () => {
316358 activeRunId: runId,
317359 });
318360 expect(shell.pendingBackgroundTasks).toEqual([
319 { taskId: "bg-2", description: "background bash", kind: "command" },
361 { taskId: "bg-2", description: "Watch build", kind: "monitor" },
320362 ]);
321363 });
322364
......@@ -443,7 +485,7 @@ describe("V2 client presentation", () => {
443485 contextHandoffId: null,
444486 };
445487 const backgroundItem = {
446 id: TurnItemId.make("item-background-command"),
488 id: TurnItemId.make("item-background-subagent"),
447489 threadId: v2Projection.thread.id,
448490 runId,
449491 nodeId: null,
......@@ -453,12 +495,18 @@ describe("V2 client presentation", () => {
453495 parentItemId: null,
454496 ordinal: 0,
455497 status: "running" as const,
456 title: "Background command",
498 title: "Background review",
457499 startedAt: now,
458500 completedAt: null,
459501 updatedAt: now,
460 type: "command_execution" as const,
461 input: "sleep 20",
502 type: "subagent" as const,
503 subagentId: NodeId.make("subagent-review"),
504 origin: "provider_native" as const,
505 driver: ProviderDriverKind.make("codex"),
506 providerInstanceId: v2Projection.thread.providerInstanceId,
507 childThreadId: null,
508 prompt: "Review the changes",
509 result: null,
462510 };
463511
464512 expect(
......@@ -488,6 +536,59 @@ describe("V2 client presentation", () => {
488536 turnItems: [backgroundItem],
489537 }),
490538 ).toMatchObject({ status: "failed", activeRunId: null });
539 const commandItem = {
540 ...backgroundItem,
541 id: TurnItemId.make("item-background-command"),
542 type: "command_execution" as const,
543 input: "npm run dev",
544 };
545 expect(
546 deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }),
547 ).toMatchObject({ status: "waiting", activeRunId: null });
548
549 for (const [turnItems, status] of [
550 [[commandItem], "completed"],
551 [[backgroundItem], "idle"],
552 [[commandItem, backgroundItem], "idle"],
553 ] as const) {
554 expect(
555 deriveThreadRuntime({
556 ...v2Projection,
557 runs: [{ ...run, status: "completed", completedAt: now }],
558 turnItems,
559 }),
560 ).toMatchObject({ status, activeRunId: null });
561 }
562
563 for (const kind of ["monitor", "background_task"] as const) {
564 const providerThread = {
565 id: ProviderThreadId.make("provider-thread-background"),
566 driver: ProviderDriverKind.make("claudeCode"),
567 providerInstanceId: v2Projection.thread.providerInstanceId,
568 providerSessionId: null,
569 appThreadId: v2Projection.thread.id,
570 ownerNodeId: null,
571 nativeThreadRef: null,
572 nativeConversationHeadRef: null,
573 status: "idle" as const,
574 firstRunOrdinal: 1,
575 lastRunOrdinal: 1,
576 handoffIds: [],
577 forkedFrom: null,
578 pendingBackgroundTasks: [{ taskId: "background-task", kind }],
579 createdAt: now,
580 updatedAt: now,
581 };
582 for (const status of ["completed", "failed"] as const) {
583 expect(
584 deriveThreadRuntime({
585 ...v2Projection,
586 runs: [{ ...run, status, completedAt: now }],
587 providerThreads: [providerThread],
588 }),
589 ).toMatchObject({ status: status === "failed" ? "failed" : "idle", activeRunId: null });
590 }
591 }
491592 });
492593
493594 it("joins pending request entities to their native turn-item display data", () => {
packages/client-runtime/src/state/models.ts+8-3
......@@ -1,3 +1,4 @@
1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
12import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
23import type {
34 ThreadLinkedPullRequest,
......@@ -159,15 +160,19 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
159160 );
160161}
161162
162// Park runtime at idle when the post-settlement background roster is nonempty
163// so #4415 waiting-presentation Waiting (session.idle) can consume CTM runtime.
163// Park runtime at idle when the post-settlement background roster holds the
164// run's completion, so #4415 waiting-presentation Waiting (session.idle) can
165// consume CTM runtime. Only work that wakes the agent holds it: commands it
166// left running, such as a dev server, present the run's own status (#14872).
164167// The server suppresses the roster while an interruptible activity run exists,
165168// so a remaining roster is stronger than checkpoint-oriented waiting.
166169// latestRun keeps the latest run's status for history presentation.
167170// A failed latest run outranks the roster, so the failure stays visible.
168171function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
169172 if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null;
170 const parkAtIdle = (thread.pendingBackgroundTasks?.length ?? 0) > 0 && thread.status !== "failed";
173 const parkAtIdle =
174 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
175 thread.status !== "failed";
171176 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
172177 return {
173178 status,
packages/client-runtime/src/state/threadExecution.ts+6-3
......@@ -236,18 +236,21 @@ export function deriveThreadRuntime(
236236 if (latestRun === null && projection.thread.activeProviderThreadId === null) return null;
237237 const activeRunId =
238238 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;
239 const hasPendingBackgroundTasks =
239 // Same rule as the shell runtime: only background work that holds the
240 // completion parks the thread at idle; a dev server left running does not.
241 const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion(
240242 derivePendingBackgroundWork({
241243 latestRun: latestRunProjection,
242244 providerThreads: projection.providerThreads,
243245 turnItems: projection.turnItems,
244246 activeProviderThreadId: projection.thread.activeProviderThreadId,
245247 runs: projection.runs,
246 }).length > 0;
248 }),
249 );
247250 return {
248251 status: usageLimitedRun
249252 ? "failed"
250 : hasPendingBackgroundTasks && latestRunProjection?.status !== "failed"
253 : backgroundWorkHoldsRun && latestRunProjection?.status !== "failed"
251254 ? "idle"
252255 : (activityRun?.status ?? "idle"),
253256 activeRunId,