authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 19:38:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 19:38:35-07:00
log2d737465027b18cafd0c4704e9025b124bc94b2c
tree9733b8fb396ab0c500bf0d5079ecc12ba9501bd3
parentc07575f573dd3a1af4f734297d17f7c951c95f10
signature Signed by PGP key B5690EEEBB952194

fix(web): disconnect offline servers from threads (#11671)


3 files changed, 172 insertions(+), 35 deletions(-)

apps/web/src/components/ChatView.tsx+82-35
...@@ -308,6 +308,8 @@ import {...@@ -308,6 +308,8 @@ import {
308} from "../lib/composerContextRecords";308} from "../lib/composerContextRecords";
309import { type ReviewCommentContext } from "../reviewCommentContext";309import { type ReviewCommentContext } from "../reviewCommentContext";
310import { environmentCatalog } from "../connection/catalog";310import { environmentCatalog } from "../connection/catalog";
311import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
312import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay";
311import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";313import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
312import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";314import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
313import { useEnvironmentQuery } from "../state/query";315import { useEnvironmentQuery } from "../state/query";
...@@ -1497,6 +1499,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1497,6 +1499,9 @@ export default function ChatView(props: ChatViewProps) {
1497 const { environments } = useEnvironments();1499 const { environments } = useEnvironments();
1498 const primaryEnvironment = usePrimaryEnvironment();1500 const primaryEnvironment = usePrimaryEnvironment();
1499 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });1501 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
1502 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
1503 reportFailure: false,
1504 });
1500 const environmentById = useMemo(1505 const environmentById = useMemo(
1501 () => new Map(environments.map((environment) => [environment.environmentId, environment])),1506 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
1502 [environments],1507 [environments],
...@@ -2208,6 +2213,37 @@ export default function ChatView(props: ChatViewProps) {...@@ -2208,6 +2213,37 @@ export default function ChatView(props: ChatViewProps) {
2208 },2213 },
2209 [retryEnvironment],2214 [retryEnvironment],
2210 );2215 );
2216 const disconnectDelayElapsed = useEnvironmentDisconnectDelay(
2217 activeEnvironmentUnavailable ? activeEnvironment.environmentId : null,
2218 );
2219 const canDisconnectActiveEnvironment =
2220 disconnectDelayElapsed &&
2221 activeEnvironment !== null &&
2222 activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" &&
2223 !isDesktopLocalConnectionTarget(activeEnvironment.entry.target);
2224 const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false);
2225 const handleDisconnectActiveEnvironment = useCallback(
2226 async (environmentId: EnvironmentId) => {
2227 setDisconnectingEnvironment(true);
2228 const result = await setEnvironmentEnabled({ environmentId, enabled: false });
2229 setDisconnectingEnvironment(false);
2230 if (result._tag === "Failure") {
2231 if (!isAtomCommandInterrupted(result)) {
2232 const error = squashAtomCommandFailure(result);
2233 toastManager.add(
2234 stackedThreadToast({
2235 type: "error",
2236 title: "Could not disconnect server",
2237 description: error instanceof Error ? error.message : "Failed to disconnect.",
2238 }),
2239 );
2240 }
2241 return;
2242 }
2243 void navigate({ to: "/", replace: true });
2244 },
2245 [navigate, setEnvironmentEnabled],
2246 );
2211 const logicalProjectEnvironments = useMemo(() => {2247 const logicalProjectEnvironments = useMemo(() => {
2212 if (!activeProject) return [];2248 if (!activeProject) return [];
2213 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);2249 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
...@@ -2448,6 +2484,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -2448,6 +2484,20 @@ export default function ChatView(props: ChatViewProps) {
2448 const items: ComposerBannerStackItem[] = [];2484 const items: ComposerBannerStackItem[] = [];
2449 const updateRunning = serverUpdateState.status === "running";2485 const updateRunning = serverUpdateState.status === "running";
2450 const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null;2486 const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null;
2487 const disconnectAction =
2488 canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? (
2489 <Button
2490 size="xs"
2491 variant="ghost"
2492 disabled={disconnectingEnvironment}
2493 title="Hide this server's threads. Switch it on again in Connections."
2494 onClick={() =>
2495 void handleDisconnectActiveEnvironment(activeEnvironmentUnavailableState.environmentId)
2496 }
2497 >
2498 Disconnect server
2499 </Button>
2500 ) : undefined;
2451 const environmentReconnecting =2501 const environmentReconnecting =
2452 unavailableConnection !== null &&2502 unavailableConnection !== null &&
2453 (unavailableConnection.phase === "connecting" ||2503 (unavailableConnection.phase === "connecting" ||
...@@ -2481,6 +2531,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2481,6 +2531,7 @@ export default function ChatView(props: ChatViewProps) {
2481 ),2531 ),
2482 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,2532 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
2483 description: "Finishing an update",2533 description: "Finishing an update",
2534 actions: disconnectAction,
2484 });2535 });
2485 } else {2536 } else {
2486 items.push({2537 items.push({
...@@ -2488,28 +2539,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -2488,28 +2539,22 @@ export default function ChatView(props: ChatViewProps) {
2488 variant: unavailableConnection.phase === "error" ? "error" : "warning",2539 variant: unavailableConnection.phase === "error" ? "error" : "warning",
2489 icon: <WifiOffIcon />,2540 icon: <WifiOffIcon />,
2490 title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,2541 title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
2491 description: environmentReconnecting ? "Trying again" : "Reconnect to continue",
2492 actions: (2542 actions: (
2493 <>2543 <>
2494 <Button2544 {!environmentReconnecting ? (
2495 size="xs"2545 <Button
2496 variant="ghost"2546 size="xs"
2497 disabled={environmentReconnecting}2547 variant="ghost"
2498 onClick={() =>2548 onClick={() =>
2499 void handleReconnectActiveEnvironment(2549 void handleReconnectActiveEnvironment(
2500 activeEnvironmentUnavailableState.environmentId,2550 activeEnvironmentUnavailableState.environmentId,
2501 )2551 )
2502 }2552 }
2503 >2553 >
2504 {environmentReconnecting ? "Reconnecting..." : "Reconnect"}2554 Reconnect
2505 </Button>2555 </Button>
2506 <Button2556 ) : null}
2507 size="xs"2557 {disconnectAction}
2508 variant="ghost"
2509 onClick={() => void navigate({ to: "/settings/connections" })}
2510 >
2511 Connections
2512 </Button>
2513 </>2558 </>
2514 ),2559 ),
2515 });2560 });
...@@ -2564,22 +2609,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -2564,22 +2609,22 @@ export default function ChatView(props: ChatViewProps) {
2564 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)2609 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
2565 ? serverUpdateGuidance(versionMismatchSelfUpdate)2610 ? serverUpdateGuidance(versionMismatchSelfUpdate)
2566 : undefined,2611 : undefined,
2567 actions:2612 actions: updateInProgress ? (
2568 updateInProgress ||2613 disconnectAction
2569 !versionMismatch ||2614 ) : !versionMismatch ||
2570 (versionMismatchSelfUpdate === "desktop-managed" &&2615 (versionMismatchSelfUpdate === "desktop-managed" &&
2571 !versionMismatchDesktopAppUpdate) ? undefined : (2616 !versionMismatchDesktopAppUpdate) ? undefined : (
2572 <ServerUpdateAction2617 <ServerUpdateAction
2573 environmentId={serverUpdateEnvironmentId}2618 environmentId={serverUpdateEnvironmentId}
2574 serverLabel={versionMismatchServerLabel}2619 serverLabel={versionMismatchServerLabel}
2575 selfUpdate={versionMismatchSelfUpdate}2620 selfUpdate={versionMismatchSelfUpdate}
2576 desktopAppUpdate={versionMismatchDesktopAppUpdate}2621 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2577 threadContinuation={versionMismatchThreadContinuation}2622 threadContinuation={versionMismatchThreadContinuation}
2578 targetVersion={versionMismatch.clientVersion}2623 targetVersion={versionMismatch.clientVersion}
2579 label={updateFailed ? "Retry" : "Update"}2624 label={updateFailed ? "Retry" : "Update"}
2580 variant="ghost"2625 variant="ghost"
2581 />2626 />
2582 ),2627 ),
2583 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)2628 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
2584 ? {}2629 ? {}
2585 : {2630 : {
...@@ -2603,7 +2648,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -2603,7 +2648,9 @@ export default function ChatView(props: ChatViewProps) {
2603 activeEnvironmentUnavailableState,2648 activeEnvironmentUnavailableState,
2604 reconnectWarningGraceElapsed,2649 reconnectWarningGraceElapsed,
2605 handleReconnectActiveEnvironment,2650 handleReconnectActiveEnvironment,
2606 navigate,2651 canDisconnectActiveEnvironment,
2652 disconnectingEnvironment,
2653 handleDisconnectActiveEnvironment,
2607 setDismissedVersionMismatchKey,2654 setDismissedVersionMismatchKey,
2608 showVersionMismatchBanner,2655 showVersionMismatchBanner,
2609 serverUpdateFailureDismissed,2656 serverUpdateFailureDismissed,
apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx created+65
...@@ -0,0 +1,65 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { act, useLayoutEffect } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6import { useEnvironmentDisconnectDelay } from "./useEnvironmentDisconnectDelay";
7
8const environmentId = EnvironmentId.make("remote");
9let renderer: ReactTestRenderer;
10let elapsed = false;
11
12function Probe({ unavailableId }: { unavailableId: EnvironmentId | null }) {
13 const value = useEnvironmentDisconnectDelay(unavailableId);
14 useLayoutEffect(() => {
15 elapsed = value;
16 });
17 return null;
18}
19
20beforeEach(() => {
21 vi.useFakeTimers();
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 act(() => {
24 renderer = create(<Probe unavailableId={environmentId} />);
25 });
26});
27
28afterEach(() => {
29 act(() => renderer.unmount());
30 vi.useRealTimers();
31 vi.unstubAllGlobals();
32});
33
34it("waits 20 seconds without restarting on renders for the same environment", () => {
35 act(() => vi.advanceTimersByTime(10_000));
36 expect(elapsed).toBe(false);
37 act(() => renderer.update(<Probe unavailableId={environmentId} />));
38 act(() => vi.advanceTimersByTime(9_999));
39 expect(elapsed).toBe(false);
40 act(() => vi.advanceTimersByTime(1));
41 expect(elapsed).toBe(true);
42});
43
44it("cancels a brief outage and starts a fresh delay on the next outage", () => {
45 act(() => vi.advanceTimersByTime(10_000));
46 act(() => renderer.update(<Probe unavailableId={null} />));
47 act(() => vi.advanceTimersByTime(20_000));
48 expect(elapsed).toBe(false);
49 act(() => renderer.update(<Probe unavailableId={environmentId} />));
50 act(() => vi.advanceTimersByTime(19_999));
51 expect(elapsed).toBe(false);
52 act(() => vi.advanceTimersByTime(1));
53 expect(elapsed).toBe(true);
54 act(() => renderer.update(<Probe unavailableId={null} />));
55 expect(elapsed).toBe(false);
56});
57
58it("does not carry elapsed time to another environment", () => {
59 act(() => vi.advanceTimersByTime(20_000));
60 expect(elapsed).toBe(true);
61 act(() => renderer.update(<Probe unavailableId={EnvironmentId.make("another-remote")} />));
62 expect(elapsed).toBe(false);
63 act(() => vi.advanceTimersByTime(20_000));
64 expect(elapsed).toBe(true);
65});
apps/web/src/hooks/useEnvironmentDisconnectDelay.ts created+25
...@@ -0,0 +1,25 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useState } from "react";
3
4/** Wait through brief outages before offering to switch off the active environment. */
5export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) {
6 const [delay, setDelay] = useState({ environmentId: unavailableEnvironmentId, elapsed: false });
7 if (delay.environmentId !== unavailableEnvironmentId) {
8 setDelay({ environmentId: unavailableEnvironmentId, elapsed: false });
9 }
10
11 useEffect(() => {
12 if (unavailableEnvironmentId === null) return;
13 const timeout = setTimeout(
14 () => setDelay({ environmentId: unavailableEnvironmentId, elapsed: true }),
15 20_000,
16 );
17 return () => clearTimeout(timeout);
18 }, [unavailableEnvironmentId]);
19
20 return (
21 unavailableEnvironmentId !== null &&
22 delay.environmentId === unavailableEnvironmentId &&
23 delay.elapsed
24 );
25}