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 {
66 ThreadId,
77 TurnId,
88} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
9import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1010
1111import type { Thread, ThreadShell } from "../types";
1212import {
......@@ -19,13 +19,16 @@ import {
1919 createLocalDispatchSnapshot,
2020 deriveComposerSendState,
2121 dismissBranchMismatchForSession,
22 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
2223 getStartedThreadModelChangeBlockReason,
24 hasEnvironmentReconnectWarningGraceElapsed,
2325 hasServerAcknowledgedLocalDispatch,
2426 isBranchMismatchDismissedForSession,
2527 reconcileMountedTerminalThreadIds,
2628 reconcileRetainedMountedThreadIds,
2729 resolveThreadMetadataUpdateForNextTurn,
2830 resolveSendEnvMode,
31 scheduleEnvironmentReconnectWarning,
2932 startNewThreadForProject,
3033 shouldShowBranchMismatchBanner,
3134 shouldWriteThreadErrorToCurrentServerThread,
......@@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1");
3639const threadId = ThreadId.make("thread-1");
3740const 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
3978function makeThread(overrides: Partial<Thread> = {}): Thread {
4079 return {
4180 id: threadId,
apps/web/src/components/ChatView.logic.ts+13
......@@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";
2525export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2626export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
2727export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
28export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
2829
2930export 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
3144export function startNewThreadForProject(
3245 projectRef: ScopedProjectRef | null,
3346 handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>,
apps/web/src/components/ChatView.tsx+24-1
......@@ -283,6 +283,8 @@ import {
283283 createLocalDispatchSnapshot,
284284 deriveComposerSendState,
285285 dismissBranchMismatchForSession,
286 hasEnvironmentReconnectWarningGraceElapsed,
287 scheduleEnvironmentReconnectWarning,
286288 hasServerAcknowledgedLocalDispatch,
287289 isBranchMismatchDismissedForSession,
288290 shouldShowBranchMismatchBanner,
......@@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) {
17331735 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
17341736 const activeEnvironmentUnavailable =
17351737 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]);
17361756 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
17371757 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
17381758 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
......@@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) {
19651985 // While an update runs, transient connect blips are expected (the server
19661986 // restarts) and the update banner already shows progress. Hard failure
19671987 // phases still surface so the Reconnect action stays reachable.
1968 const suppressUnavailableBanner = updateRunning && environmentReconnecting;
1988 const suppressUnavailableBanner =
1989 environmentReconnecting &&
1990 (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed));
19691991 if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) {
19701992 if (reconnectingThroughVersionSkew) {
19711993 items.push({
......@@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) {
20932115 return items;
20942116 }, [
20952117 activeEnvironmentUnavailableState,
2118 reconnectWarningGraceElapsed,
20962119 handleReconnectActiveEnvironment,
20972120 navigate,
20982121 setDismissedVersionMismatchKey,