From c03372301346f9e656c7319ce3e1f32cf8a74a2f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:28:04 +0000 Subject: [PATCH] fix(web): delay transient reconnect warnings --- .../web/src/components/ChatView.logic.test.ts | 41 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 13 ++++++ apps/web/src/components/ChatView.tsx | 25 ++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..5c026c94a13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { @@ -19,13 +19,16 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, @@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("environment reconnect warning grace", () => { + afterEach(() => vi.useRealTimers()); + + it("shows a persistent reconnect after the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + scheduleEnvironmentReconnectWarning(showWarning); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); + expect(showWarning).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(showWarning).toHaveBeenCalledOnce(); + }); + + it("cancels the warning when the connection recovers during the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + const cancel = scheduleEnvironmentReconnectWarning(showWarning); + cancel(); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + + expect(showWarning).not.toHaveBeenCalled(); + }); + + it("does not reuse elapsed grace from another environment", () => { + const anotherEnvironmentId = EnvironmentId.make("environment-remote"); + + expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); + expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( + false, + ); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..60df1cd966f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { + const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + return () => globalThis.clearTimeout(timeoutId); +} + +export function hasEnvironmentReconnectWarningGraceElapsed( + activeEnvironmentId: EnvironmentId | null, + elapsedEnvironmentId: EnvironmentId | null, +): boolean { + return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbe1ac8d96..8b510d457fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -283,6 +283,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) { const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" + ? (activeEnvironment?.environmentId ?? null) + : null; + const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = + useState(null); + const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + useEffect(() => { + setReconnectWarningGraceElapsedEnvironmentId(null); + if (activeReconnectingEnvironmentId === null) return; + return scheduleEnvironmentReconnectWarning(() => + setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), + ); + }, [activeReconnectingEnvironmentId]); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) { // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; + const suppressUnavailableBanner = + environmentReconnecting && + (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ @@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey,