From a5b08ae2e925e35ec8c9dd7a54bfe6441bb55a07 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:28:39 -0700 Subject: [PATCH 1/2] fix(web): disconnect offline servers from threads --- apps/web/src/components/ChatView.tsx | 112 ++++++++++++++++++--------- 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7994b3196f58..342944321656 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -308,6 +308,7 @@ import { } from "../lib/composerContextRecords"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -1497,6 +1498,9 @@ export default function ChatView(props: ChatViewProps) { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], @@ -2208,6 +2212,33 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const canDisconnectActiveEnvironment = + activeEnvironment !== null && + activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && + !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); + const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); + const handleDisconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + void navigate({ to: "/", replace: true }); + }, + [navigate, setEnvironmentEnabled], + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2448,6 +2479,20 @@ export default function ChatView(props: ChatViewProps) { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : undefined; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || @@ -2481,6 +2526,7 @@ export default function ChatView(props: ChatViewProps) { ), title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, description: "Finishing an update", + actions: disconnectAction, }); } else { items.push({ @@ -2488,28 +2534,22 @@ export default function ChatView(props: ChatViewProps) { variant: unavailableConnection.phase === "error" ? "error" : "warning", icon: , title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: environmentReconnecting ? "Trying again" : "Reconnect to continue", actions: ( <> - - + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} ), }); @@ -2564,22 +2604,22 @@ export default function ChatView(props: ChatViewProps) { (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) ? serverUpdateGuidance(versionMismatchSelfUpdate) : undefined, - actions: - updateInProgress || - !versionMismatch || + actions: updateInProgress ? ( + disconnectAction + ) : !versionMismatch || (versionMismatchSelfUpdate === "desktop-managed" && !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), + + ), ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) ? {} : { @@ -2603,7 +2643,9 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, - navigate, + canDisconnectActiveEnvironment, + disconnectingEnvironment, + handleDisconnectActiveEnvironment, setDismissedVersionMismatchKey, showVersionMismatchBanner, serverUpdateFailureDismissed, From 979a48de232dd53718a0b72f0ff2283b9ab7cd9b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:34:52 -0700 Subject: [PATCH 2/2] fix(web): wait before offering to disconnect a server --- apps/web/src/components/ChatView.tsx | 5 ++ .../useEnvironmentDisconnectDelay.test.tsx | 65 +++++++++++++++++++ .../hooks/useEnvironmentDisconnectDelay.ts | 25 +++++++ 3 files changed, 95 insertions(+) create mode 100644 apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx create mode 100644 apps/web/src/hooks/useEnvironmentDisconnectDelay.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 342944321656..9e11132269e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -309,6 +309,7 @@ import { import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -2212,7 +2213,11 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, + ); const canDisconnectActiveEnvironment = + disconnectDelayElapsed && activeEnvironment !== null && activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx new file mode 100644 index 000000000000..f584112df4db --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx @@ -0,0 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useEnvironmentDisconnectDelay } from "./useEnvironmentDisconnectDelay"; + +const environmentId = EnvironmentId.make("remote"); +let renderer: ReactTestRenderer; +let elapsed = false; + +function Probe({ unavailableId }: { unavailableId: EnvironmentId | null }) { + const value = useEnvironmentDisconnectDelay(unavailableId); + useLayoutEffect(() => { + elapsed = value; + }); + return null; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + act(() => { + renderer = create(); + }); +}); + +afterEach(() => { + act(() => renderer.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("waits 20 seconds without restarting on renders for the same environment", () => { + act(() => vi.advanceTimersByTime(10_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(9_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); +}); + +it("cancels a brief outage and starts a fresh delay on the next outage", () => { + act(() => vi.advanceTimersByTime(10_000)); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(19_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); +}); + +it("does not carry elapsed time to another environment", () => { + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); +}); diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts new file mode 100644 index 000000000000..17c6f8f87be8 --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts @@ -0,0 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +/** Wait through brief outages before offering to switch off the active environment. */ +export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) { + const [delay, setDelay] = useState({ environmentId: unavailableEnvironmentId, elapsed: false }); + if (delay.environmentId !== unavailableEnvironmentId) { + setDelay({ environmentId: unavailableEnvironmentId, elapsed: false }); + } + + useEffect(() => { + if (unavailableEnvironmentId === null) return; + const timeout = setTimeout( + () => setDelay({ environmentId: unavailableEnvironmentId, elapsed: true }), + 20_000, + ); + return () => clearTimeout(timeout); + }, [unavailableEnvironmentId]); + + return ( + unavailableEnvironmentId !== null && + delay.environmentId === unavailableEnvironmentId && + delay.elapsed + ); +}