| 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,7 +6,7 @@ import { |
| 6 | ThreadId, | 6 | ThreadId, |
| 7 | TurnId, | 7 | TurnId, |
| 8 | } from "@t3tools/contracts"; | 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 | import type { Thread, ThreadShell } from "../types"; | 11 | import type { Thread, ThreadShell } from "../types"; |
| 12 | import { | 12 | import { |
| ... | @@ -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"); |
| 36 | const threadId = ThreadId.make("thread-1"); | 39 | const threadId = ThreadId.make("thread-1"); |
| 37 | const now = "2026-03-29T00:00:00.000Z"; | 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 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 78 | function 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"; |
| 25 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 25 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 26 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; | 26 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| 27 | export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; | 27 | export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; |
| 28 | export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; | ||
| 28 | 29 | ||
| 29 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | 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 | export function startNewThreadForProject( | 44 | export 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 server | 1985 | // While an update runs, transient connect blips are expected (the server |
| 1966 | // restarts) and the update banner already shows progress. Hard failure | 1986 | // 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, |