From 4d3c9b439162beeb31d5d5d8c3ed0848e10945db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 09:20:32 +0000 Subject: [PATCH 1/4] test(web): reproduce #26886 settled thread still notifies completion Settling a thread within the completion grace still presents "Thread completed". This test fails until the coordinator cancels the pending completion for settled threads. Co-Authored-By: Claude Opus 5.5 --- ...ificationCoordinator.badge.browser.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 89128ff69ac55..1165fd81f5419 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -105,6 +105,7 @@ const thread = { id: "thread", title: "Test thread", archivedAt: null as string | null, + settledAt: null as string | null, hasPendingApprovals: false, hasPendingUserInput: false, session: null as { status: string } | null, @@ -162,8 +163,8 @@ function toV2ThreadShell(input: typeof thread) { ? DateTime.makeUnsafe(latestTurn.completedAt) : undefined, archivedAt: input.archivedAt ? DateTime.makeUnsafe(input.archivedAt) : null, - settledOverride: null, - settledAt: null, + settledOverride: input.settledAt ? "settled" : null, + settledAt: input.settledAt ? DateTime.makeUnsafe(input.settledAt) : null, lastVisitedAt: null, deletedAt: null, }; @@ -260,3 +261,19 @@ it("holds a completion through a continuation gap and notifies once it settles", await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); + +it("does not notify a completion the user settles during the grace", async () => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const latestTurn = { + turnId: "turn", + state: "completed", + completedAt: "2026-09-13T07:00:01.000Z", + }; + state.shells.set("one", shell({ latestTurn })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS / 2); + state.shells.set("one", shell({ latestTurn, settledAt: "2026-09-13T07:00:02.000Z" })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual([]); +}); From 286149e63ab07684a55d04c2f9a2b13254213a99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 09:26:02 +0000 Subject: [PATCH 2/4] fix(web): settling a thread cancels its pending completion notification Settling a thread within the completion grace left the gate's timer running, so "Thread completed" still fired ~2s later. The server clears settledOverride when a new run starts, so a settled shell was settled after its latest run: treat it as quiet, which cancels and skips that completion. Fixes pingdotgg/t3code#26886 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ThreadNotificationCoordinator.logic.ts | 2 +- apps/web/src/components/ThreadNotificationCoordinator.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..cfaff628fbb82 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -40,7 +40,7 @@ export function createThreadNotificationGate( ): void { if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); if (!next.settled || options.archived || options.quiet) cancel(key); - // The first sighting, archived threads, and stopped or silent runs never notify completion. + // The first sighting, archived threads, and stopped, silent, or user-settled runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); if (!prior || options.archived) return; if (next.attention && next.attention !== prior.attention) { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fa4fdbb672faa..801bdda708dbf 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -226,7 +226,7 @@ export function startThreadNotifications( for (const [prior, state] of changed) { entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, - quiet: state.raw.latestRunStopRequested === true, + quiet: state.raw.latestRunStopRequested === true || state.raw.settledOverride === "settled", }); } }; From 94898fef395c51de857d17550cd3361dcae058d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 09:28:38 +0000 Subject: [PATCH 3/4] fix(web): keep the completion notification for auto-settled threads Auto-settles stamp settledAt with the run's own completion, and the relay still alerts those (#24769). Only a settle stamped after the completion is the user dismissing it. Co-Authored-By: Claude Opus 5.5 --- ...NotificationCoordinator.badge.browser.test.ts | 16 ++++++++++++++++ .../components/ThreadNotificationCoordinator.tsx | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 1165fd81f5419..b7a0a02404cfa 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -277,3 +277,19 @@ it("does not notify a completion the user settles during the grace", async () => await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map((sent) => sent.title)).toEqual([]); }); + +it("still notifies a completion that auto-settles during the grace", async () => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const latestTurn = { + turnId: "turn", + state: "completed", + completedAt: "2026-09-13T07:00:01.000Z", + }; + state.shells.set("one", shell({ latestTurn })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS / 2); + state.shells.set("one", shell({ latestTurn, settledAt: latestTurn.completedAt })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 801bdda708dbf..6dfc21199b58f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -224,9 +224,15 @@ export function startThreadNotifications( entry.previous = next; entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { + const { settledOverride, settledAt } = state.thread; + // Auto-settles stamp the run's own completion; a later stamp is the user dismissing it. + const userSettled = + settledOverride === "settled" && + state.completedAt !== null && + Date.parse(settledAt ?? "") > state.completedAt; entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, - quiet: state.raw.latestRunStopRequested === true || state.raw.settledOverride === "settled", + quiet: state.raw.latestRunStopRequested === true || userSettled, }); } }; From 660892dfaf361716a79816a48207ae1f99779387 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 09:37:47 +0000 Subject: [PATCH 4/4] fix(server): agent settles keep their completion notification An agent that settles its own thread is settled at turn end, stamped a moment after its run completed, which web reads as the user dismissing the completion. Stamp the run's completion instead, as auto-settles do. Co-Authored-By: Claude Opus 5.5 --- .../ThreadManagementService.test.ts | 13 +++++++++---- .../src/orchestration-v2/ThreadManagementService.ts | 2 ++ .../components/ThreadNotificationCoordinator.tsx | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index b2daad62681e9..a74732c131174 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -5,6 +5,7 @@ import { MessageId, type OrchestrationV2Command, type OrchestrationV2Run, + type OrchestrationV2ServerCommand, type OrchestrationV2ProviderThread, ProviderThreadId, ProviderDriverKind, @@ -565,7 +566,8 @@ it.effect.each([ const projectId = ProjectId.make("project:thread-management:settle-after-run"); const threadId = ThreadId.make("thread:thread-management:settle-after-run"); const runId = RunId.make("run:thread-management:settle-after-run"); - const dispatched: Array = []; + const completedAt = DateTime.makeUnsafe("2026-09-13T07:00:01.000Z"); + const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( @@ -575,11 +577,11 @@ it.effect.each([ getThreadRecords: () => Effect.succeed({ thread: { id: threadId, projectId, deletedAt: null }, - runs: [{ id: runId, status }], + runs: [{ id: runId, status, completedAt }], } as unknown as OrchestrationV2ThreadProjection), dispatch: (command) => Effect.sync(() => { - dispatched.push(command.type); + dispatched.push(command); return { sequence: 1, storedEvents: [] } as never; }), }), @@ -593,6 +595,9 @@ it.effect.each([ yield* service.settleAfterRun({ projectId, threadId, runId }); - expect(dispatched).toEqual(settles ? ["thread.settle"] : []); + // Stamped at the completion, so clients still notify it like an auto-settle. + expect(dispatched).toMatchObject( + settles ? [{ type: "thread.settle", settledAt: completedAt }] : [], + ); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index cfbbd4a68a51d..93d8c5a9bbf72 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -996,6 +996,8 @@ const make = Effect.gen(function* () { type: "thread.settle", commandId: CommandId.make(`server:settle-after-run:${input.runId}`), threadId: input.threadId, + // Like auto-settles, stamp the completion: clients only silence settles stamped after it. + ...(run.completedAt === null ? {} : { settledAt: run.completedAt }), }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 6dfc21199b58f..ae1156550d14b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -225,7 +225,7 @@ export function startThreadNotifications( entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { const { settledOverride, settledAt } = state.thread; - // Auto-settles stamp the run's own completion; a later stamp is the user dismissing it. + // Auto and agent settles stamp the run's own completion; a later stamp is the user dismissing it. const userSettled = settledOverride === "settled" && state.completedAt !== null &&