| author | |
| committer | |
| log | ed886fe1814890da30ae73c77f9e894ddc9bd481 |
| tree | 780a972157165968afa81eb73843743eeeecea84 |
| parent | 4eaf5ef8bb47b870397d5c61cd216b1a6bdd1510 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 6 | ThreadId, |
| 7 | 7 | TurnId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { describe, expect, it } from "vite-plus/test"; | |
| 9 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | 10 | |
| 11 | 11 | import type { Thread, ThreadShell } from "../types"; |
| 12 | 12 | import { |
| ... | ... | @@ -19,13 +19,16 @@ import { |
| 19 | 19 | createLocalDispatchSnapshot, |
| 20 | 20 | deriveComposerSendState, |
| 21 | 21 | dismissBranchMismatchForSession, |
| 22 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, | |
| 22 | 23 | getStartedThreadModelChangeBlockReason, |
| 24 | hasEnvironmentReconnectWarningGraceElapsed, | |
| 23 | 25 | hasServerAcknowledgedLocalDispatch, |
| 24 | 26 | isBranchMismatchDismissedForSession, |
| 25 | 27 | reconcileMountedTerminalThreadIds, |
| 26 | 28 | reconcileRetainedMountedThreadIds, |
| 27 | 29 | resolveThreadMetadataUpdateForNextTurn, |
| 28 | 30 | resolveSendEnvMode, |
| 31 | scheduleEnvironmentReconnectWarning, | |
| 29 | 32 | startNewThreadForProject, |
| 30 | 33 | shouldShowBranchMismatchBanner, |
| 31 | 34 | shouldWriteThreadErrorToCurrentServerThread, |
| ... | ... | @@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1"); |
| 36 | 39 | const threadId = ThreadId.make("thread-1"); |
| 37 | 40 | const now = "2026-03-29T00:00:00.000Z"; |
| 38 | 41 | |
| 42 | describe("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 | ||
| 39 | 78 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 40 | 79 | return { |
| 41 | 80 | id: threadId, |
apps/web/src/components/ChatView.logic.ts+13| ... | ... | @@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 25 | 25 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 26 | 26 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| 27 | 27 | export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; |
| 28 | export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; | |
| 28 | 29 | |
| 29 | 30 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 30 | 31 | |
| 32 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { | |
| 33 | const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); | |
| 34 | return () => globalThis.clearTimeout(timeoutId); | |
| 35 | } | |
| 36 | ||
| 37 | export function hasEnvironmentReconnectWarningGraceElapsed( | |
| 38 | activeEnvironmentId: EnvironmentId | null, | |
| 39 | elapsedEnvironmentId: EnvironmentId | null, | |
| 40 | ): boolean { | |
| 41 | return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; | |
| 42 | } | |
| 43 | ||
| 31 | 44 | export function startNewThreadForProject( |
| 32 | 45 | projectRef: ScopedProjectRef | null, |
| 33 | 46 | handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>, |
apps/web/src/components/ChatView.tsx+24-1| ... | ... | @@ -283,6 +283,8 @@ import { |
| 283 | 283 | createLocalDispatchSnapshot, |
| 284 | 284 | deriveComposerSendState, |
| 285 | 285 | dismissBranchMismatchForSession, |
| 286 | hasEnvironmentReconnectWarningGraceElapsed, | |
| 287 | scheduleEnvironmentReconnectWarning, | |
| 286 | 288 | hasServerAcknowledgedLocalDispatch, |
| 287 | 289 | isBranchMismatchDismissedForSession, |
| 288 | 290 | shouldShowBranchMismatchBanner, |
| ... | ... | @@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 1733 | 1735 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| 1734 | 1736 | const activeEnvironmentUnavailable = |
| 1735 | 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 | 1756 | const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; |
| 1737 | 1757 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { |
| 1738 | 1758 | if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { |
| ... | ... | @@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1965 | 1985 | // While an update runs, transient connect blips are expected (the server |
| 1966 | 1986 | // restarts) and the update banner already shows progress. Hard failure |
| 1967 | 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 | 1991 | if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { |
| 1970 | 1992 | if (reconnectingThroughVersionSkew) { |
| 1971 | 1993 | items.push({ |
| ... | ... | @@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2093 | 2115 | return items; |
| 2094 | 2116 | }, [ |
| 2095 | 2117 | activeEnvironmentUnavailableState, |
| 2118 | reconnectWarningGraceElapsed, | |
| 2096 | 2119 | handleReconnectActiveEnvironment, |
| 2097 | 2120 | navigate, |
| 2098 | 2121 | setDismissedVersionMismatchKey, |