Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number];
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;

const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;

export function agentControlledBrowserCloseConfirmation(
surfaces: readonly RightPanelSurface[],
Expand Down Expand Up @@ -467,18 +467,6 @@ export function resolveDraftPromotionNavigationTarget(input: {
return runStarted || startupStopped || messagePersisted ? input.serverThreadRef : null;
}

export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
return () => globalThis.clearTimeout(timeoutId);
}

export function hasEnvironmentReconnectWarningGraceElapsed(
activeEnvironmentId: EnvironmentId | null,
elapsedEnvironmentId: EnvironmentId | null,
): boolean {
return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId;
}

export function startNewThreadForProject(
projectRef: ScopedProjectRef | null,
handleNewThread: (projectRef: ScopedProjectRef) => Promise<unknown>,
Expand Down
31 changes: 8 additions & 23 deletions apps/web/src/components/chat/useSystemComposerBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { type EnvironmentId } from "@t3tools/contracts";
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
isAtomCommandInterrupted,
Expand All @@ -13,14 +13,14 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
import { environmentCatalog } from "../../connection/catalog";
import { ConnectionDiagnosisHeadline } from "../settings/ConnectionDiagnosisDetails";
import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
import { useEnvironmentDisconnectDelay } from "../../hooks/useEnvironmentDisconnectDelay";
import {
useEnvironmentDisconnectDelay,
useEnvironmentStateHeldFor,
} from "../../hooks/useEnvironmentDisconnectDelay";
import { serverEnvironment } from "../../state/server";
import { type EnvironmentPresentation } from "../../state/environments";
import type { ComposerBannerStackItem } from "./ComposerBannerStack";
import {
hasEnvironmentReconnectWarningGraceElapsed,
scheduleEnvironmentReconnectWarning,
} from "../ChatView.logic";
import { ENVIRONMENT_RECONNECT_WARNING_GRACE_MS } from "../ChatView.logic";
import { useAtomCommand } from "../../state/use-atom-command";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
Expand Down Expand Up @@ -83,27 +83,12 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) {
activeEnvironmentConnectionPhase === "reconnecting"
? (activeEnvironment?.environmentId ?? null)
: null;
const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] =
useState<EnvironmentId | null>(null);
// Each reconnect attempt gets its own grace period, including a later one to
// the same environment.
const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState(
activeReconnectingEnvironmentId,
);
if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) {
setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId);
setReconnectWarningGraceElapsedEnvironmentId(null);
}
const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed(
const reconnectWarningGraceElapsed = useEnvironmentStateHeldFor(
activeReconnectingEnvironmentId,
reconnectWarningGraceElapsedEnvironmentId,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
);
useEffect(() => {
if (activeReconnectingEnvironmentId === null) return;
return scheduleEnvironmentReconnectWarning(() =>
setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId),
);
}, [activeReconnectingEnvironmentId]);
const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => {
const result = await retryEnvironment(environmentId);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/hooks/useEnvironmentDisconnectDelay.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import { act, fakeTimers, render } from "../../test/render";
import { useEnvironmentStateHeldFor } from "./useEnvironmentDisconnectDelay";

const first = EnvironmentId.make("env-first");
const second = EnvironmentId.make("env-second");

function Probe(props: { environmentId: EnvironmentId | null }) {
const held = useEnvironmentStateHeldFor(props.environmentId, 1_000);
return <output>{held ? "held" : "waiting"}</output>;
}

describe("useEnvironmentStateHeldFor", () => {
it("restarts the delay whenever the environment changes", async () => {
await using screen = await render(<Probe environmentId={first} />, {
// A bare hook probe; nothing here navigates.
router: false,
});
using _timers = fakeTimers();
await screen.rerender(<Probe environmentId={second} />);
await expect.element(screen.getByText("waiting")).toBeInTheDocument();

await act(() => vi.advanceTimersByTime(999));
await expect.element(screen.getByText("waiting")).toBeInTheDocument();

await act(() => vi.advanceTimersByTime(1));
await expect.element(screen.getByText("held")).toBeInTheDocument();

await screen.rerender(<Probe environmentId={null} />);
await expect.element(screen.getByText("waiting")).toBeInTheDocument();
await screen.rerender(<Probe environmentId={second} />);
await expect.element(screen.getByText("waiting")).toBeInTheDocument();
await act(() => vi.advanceTimersByTime(1_000));
await expect.element(screen.getByText("held")).toBeInTheDocument();
});
});
41 changes: 22 additions & 19 deletions apps/web/src/hooks/useEnvironmentDisconnectDelay.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { deadlineAfter } from "@t3tools/client-runtime/clock";
import type { EnvironmentId } from "@t3tools/contracts";
import { useEffect, useState } from "react";
import { useState } from "react";

export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) {
const [delay, setDelay] = useState({ environmentId: unavailableEnvironmentId, elapsed: false });
if (delay.environmentId !== unavailableEnvironmentId) {
setDelay({ environmentId: unavailableEnvironmentId, elapsed: false });
}
import { useDeadlinePassed } from "./useDeadline";

const DISCONNECT_DELAY_MS = 20_000;

useEffect(() => {
if (unavailableEnvironmentId === null) return;
const timeout = setTimeout(
() => setDelay({ environmentId: unavailableEnvironmentId, elapsed: true }),
20_000,
);
return () => clearTimeout(timeout);
}, [unavailableEnvironmentId]);
export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) {
return useEnvironmentStateHeldFor(unavailableEnvironmentId, DISCONNECT_DELAY_MS);
}

return (
unavailableEnvironmentId !== null &&
delay.environmentId === unavailableEnvironmentId &&
delay.elapsed
);
/**
* Whether `environmentId` has stayed the same non-null value for `delayMs`. Every change starts a
* new delay, so leaving and returning to an id restarts it.
*/
export function useEnvironmentStateHeldFor(environmentId: EnvironmentId | null, delayMs: number) {
const [held, setHeld] = useState(() => ({
environmentId,
deadline: deadlineAfter(delayMs),
}));
if (held.environmentId !== environmentId) {
setHeld({ environmentId, deadline: deadlineAfter(delayMs) });
}
const elapsed = useDeadlinePassed(held.environmentId === null ? null : held.deadline);
return environmentId !== null && held.environmentId === environmentId && elapsed;
}
Loading