authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-07 23:17:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 23:17:18-04:00
loged886fe1814890da30ae73c77f9e894ddc9bd481
tree780a972157165968afa81eb73843743eeeecea84
parent4eaf5ef8bb47b870397d5c61cd216b1a6bdd1510
signature Signed by PGP key B5690EEEBB952194

fix(web): delay transient reconnect warnings (#5670)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>

3 files changed, 77 insertions(+), 2 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+40-1
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 ThreadId,6 ThreadId,
7 TurnId,7 TurnId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";9import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1010
11import type { Thread, ThreadShell } from "../types";11import type { Thread, ThreadShell } from "../types";
12import {12import {
...@@ -19,13 +19,16 @@ import {...@@ -19,13 +19,16 @@ import {
19 createLocalDispatchSnapshot,19 createLocalDispatchSnapshot,
20 deriveComposerSendState,20 deriveComposerSendState,
21 dismissBranchMismatchForSession,21 dismissBranchMismatchForSession,
22 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
22 getStartedThreadModelChangeBlockReason,23 getStartedThreadModelChangeBlockReason,
24 hasEnvironmentReconnectWarningGraceElapsed,
23 hasServerAcknowledgedLocalDispatch,25 hasServerAcknowledgedLocalDispatch,
24 isBranchMismatchDismissedForSession,26 isBranchMismatchDismissedForSession,
25 reconcileMountedTerminalThreadIds,27 reconcileMountedTerminalThreadIds,
26 reconcileRetainedMountedThreadIds,28 reconcileRetainedMountedThreadIds,
27 resolveThreadMetadataUpdateForNextTurn,29 resolveThreadMetadataUpdateForNextTurn,
28 resolveSendEnvMode,30 resolveSendEnvMode,
31 scheduleEnvironmentReconnectWarning,
29 startNewThreadForProject,32 startNewThreadForProject,
30 shouldShowBranchMismatchBanner,33 shouldShowBranchMismatchBanner,
31 shouldWriteThreadErrorToCurrentServerThread,34 shouldWriteThreadErrorToCurrentServerThread,
...@@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1");...@@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1");
36const threadId = ThreadId.make("thread-1");39const threadId = ThreadId.make("thread-1");
37const now = "2026-03-29T00:00:00.000Z";40const now = "2026-03-29T00:00:00.000Z";
3841
42describe("environment reconnect warning grace", () => {
43 afterEach(() => vi.useRealTimers());
44
45 it("shows a persistent reconnect after the grace period", () => {
46 vi.useFakeTimers();
47 const showWarning = vi.fn();
48
49 scheduleEnvironmentReconnectWarning(showWarning);
50 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1);
51 expect(showWarning).not.toHaveBeenCalled();
52
53 vi.advanceTimersByTime(1);
54 expect(showWarning).toHaveBeenCalledOnce();
55 });
56
57 it("cancels the warning when the connection recovers during the grace period", () => {
58 vi.useFakeTimers();
59 const showWarning = vi.fn();
60
61 const cancel = scheduleEnvironmentReconnectWarning(showWarning);
62 cancel();
63 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
64
65 expect(showWarning).not.toHaveBeenCalled();
66 });
67
68 it("does not reuse elapsed grace from another environment", () => {
69 const anotherEnvironmentId = EnvironmentId.make("environment-remote");
70
71 expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true);
72 expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe(
73 false,
74 );
75 });
76});
77
39function makeThread(overrides: Partial<Thread> = {}): Thread {78function makeThread(overrides: Partial<Thread> = {}): Thread {
40 return {79 return {
41 id: threadId,80 id: threadId,
apps/web/src/components/ChatView.logic.ts+13
...@@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";...@@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";
25export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";25export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
26export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;26export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
27export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;27export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
28export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
2829
29export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);30export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
3031
32export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
33 const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
34 return () => globalThis.clearTimeout(timeoutId);
35}
36
37export function hasEnvironmentReconnectWarningGraceElapsed(
38 activeEnvironmentId: EnvironmentId | null,
39 elapsedEnvironmentId: EnvironmentId | null,
40): boolean {
41 return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId;
42}
43
31export function startNewThreadForProject(44export function startNewThreadForProject(
32 projectRef: ScopedProjectRef | null,45 projectRef: ScopedProjectRef | null,
33 handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>,46 handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>,
apps/web/src/components/ChatView.tsx+24-1
...@@ -283,6 +283,8 @@ import {...@@ -283,6 +283,8 @@ import {
283 createLocalDispatchSnapshot,283 createLocalDispatchSnapshot,
284 deriveComposerSendState,284 deriveComposerSendState,
285 dismissBranchMismatchForSession,285 dismissBranchMismatchForSession,
286 hasEnvironmentReconnectWarningGraceElapsed,
287 scheduleEnvironmentReconnectWarning,
286 hasServerAcknowledgedLocalDispatch,288 hasServerAcknowledgedLocalDispatch,
287 isBranchMismatchDismissedForSession,289 isBranchMismatchDismissedForSession,
288 shouldShowBranchMismatchBanner,290 shouldShowBranchMismatchBanner,
...@@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) {
1733 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";1735 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
1734 const activeEnvironmentUnavailable =1736 const activeEnvironmentUnavailable =
1735 activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";1737 activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";
1738 const activeReconnectingEnvironmentId =
1739 activeEnvironmentConnectionPhase === "connecting" ||
1740 activeEnvironmentConnectionPhase === "reconnecting"
1741 ? (activeEnvironment?.environmentId ?? null)
1742 : null;
1743 const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] =
1744 useState<EnvironmentId | null>(null);
1745 const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed(
1746 activeReconnectingEnvironmentId,
1747 reconnectWarningGraceElapsedEnvironmentId,
1748 );
1749 useEffect(() => {
1750 setReconnectWarningGraceElapsedEnvironmentId(null);
1751 if (activeReconnectingEnvironmentId === null) return;
1752 return scheduleEnvironmentReconnectWarning(() =>
1753 setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId),
1754 );
1755 }, [activeReconnectingEnvironmentId]);
1736 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;1756 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
1737 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {1757 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
1738 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {1758 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
...@@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) {
1965 // While an update runs, transient connect blips are expected (the server1985 // While an update runs, transient connect blips are expected (the server
1966 // restarts) and the update banner already shows progress. Hard failure1986 // restarts) and the update banner already shows progress. Hard failure
1967 // phases still surface so the Reconnect action stays reachable.1987 // phases still surface so the Reconnect action stays reachable.
1968 const suppressUnavailableBanner = updateRunning && environmentReconnecting;1988 const suppressUnavailableBanner =
1989 environmentReconnecting &&
1990 (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed));
1969 if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) {1991 if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) {
1970 if (reconnectingThroughVersionSkew) {1992 if (reconnectingThroughVersionSkew) {
1971 items.push({1993 items.push({
...@@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) {
2093 return items;2115 return items;
2094 }, [2116 }, [
2095 activeEnvironmentUnavailableState,2117 activeEnvironmentUnavailableState,
2118 reconnectWarningGraceElapsed,
2096 handleReconnectActiveEnvironment,2119 handleReconnectActiveEnvironment,
2097 navigate,2120 navigate,
2098 setDismissedVersionMismatchKey,2121 setDismissedVersionMismatchKey,