diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 7994b3196f58edc031d943ab01ba4bc76347fd06..9e11132269e6bdae9284315e40776b04da5b5ea3 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -308,6 +308,8 @@ import {
} from "../lib/composerContextRecords";
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";
@@ -1497,6 +1499,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 +2213,37 @@ 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);
+ 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 +2484,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 +2531,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 +2539,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 +2609,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 +2648,9 @@ export default function ChatView(props: ChatViewProps) {
activeEnvironmentUnavailableState,
reconnectWarningGraceElapsed,
handleReconnectActiveEnvironment,
- navigate,
+ canDisconnectActiveEnvironment,
+ disconnectingEnvironment,
+ handleDisconnectActiveEnvironment,
setDismissedVersionMismatchKey,
showVersionMismatchBanner,
serverUpdateFailureDismissed,
diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f584112df4dbdae0218940114721c81903e6edce
--- /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 0000000000000000000000000000000000000000..17c6f8f87be86923c9a22b1b7230d1e9561a52f3
--- /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
+ );
+}