();
@@ -10,13 +10,32 @@ export function ProjectFavicon(input: {
cwd: string;
className?: string;
}) {
+ const { presentationById } = useWebEnvironments();
const src = (() => {
try {
- return resolveEnvironmentHttpUrl({
- environmentId: input.environmentId,
- pathname: "/api/project-favicon",
- searchParams: { cwd: input.cwd },
- });
+ const baseUrl = presentationById.get(input.environmentId)
+ ? (() => {
+ const entry = presentationById.get(input.environmentId)!.entry;
+ switch (entry.target._tag) {
+ case "PrimaryConnectionTarget":
+ return entry.target.httpBaseUrl;
+ case "BearerConnectionTarget":
+ return entry.profile._tag === "Some" &&
+ entry.profile.value._tag === "BearerConnectionProfile"
+ ? entry.profile.value.httpBaseUrl
+ : null;
+ case "RelayConnectionTarget":
+ case "SshConnectionTarget":
+ return null;
+ }
+ })()
+ : null;
+ if (baseUrl === null) {
+ return null;
+ }
+ const url = new URL("/api/project-favicon", baseUrl);
+ url.searchParams.set("cwd", input.cwd);
+ return url.toString();
} catch {
return null;
}
diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx
index 69cd83bf8dc..c14cf2aa6a0 100644
--- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx
+++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx
@@ -3,7 +3,8 @@ import { DownloadIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
-import { ensureLocalApi } from "../localApi";
+import { useWebActions } from "../connection/useWebEnvironmentData";
+import { useWebPrimaryEnvironment } from "../connection/useWebEnvironments";
import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
import { useServerProviders } from "../rpc/serverState";
import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
@@ -102,6 +103,8 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {
export function ProviderUpdateLaunchNotification() {
const navigate = useNavigate();
const providers = useServerProviders();
+ const primaryEnvironment = useWebPrimaryEnvironment();
+ const actions = useWebActions();
const activeToastRef = useRef
(null);
const { dismissedNotificationKeys, dismissNotificationKey } =
useDismissedProviderUpdateNotificationKeys();
@@ -185,7 +188,7 @@ export function ProviderUpdateLaunchNotification() {
};
const runUpdates = () => {
- if (updateStarted || oneClickProviders.length === 0) {
+ if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) {
return;
}
updateStarted = true;
@@ -208,9 +211,12 @@ export function ProviderUpdateLaunchNotification() {
void Promise.allSettled(
oneClickProviders.map(async (provider) =>
- ensureLocalApi().server.updateProvider({
- provider: provider.driver,
- instanceId: provider.instanceId,
+ actions.server.updateProvider({
+ environmentId: primaryEnvironment.environmentId,
+ input: {
+ provider: provider.driver,
+ instanceId: provider.instanceId,
+ },
}),
),
).then((results) => {
@@ -288,11 +294,13 @@ export function ProviderUpdateLaunchNotification() {
);
activeToastRef.current = { kind: "prompt", key: notificationKey, toastId };
}, [
+ actions.server,
dismissNotificationKey,
dismissedNotificationKeys,
notificationKey,
oneClickProviders,
openProviderSettings,
+ primaryEnvironment,
updateProviders,
]);
diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx
index 688ea004f52..97e037b43fe 100644
--- a/apps/web/src/components/PullRequestThreadDialog.tsx
+++ b/apps/web/src/components/PullRequestThreadDialog.tsx
@@ -173,9 +173,7 @@ export function PullRequestThreadDialog({
const errorMessage =
validationMessage ??
(resolvedPullRequest === null && pullRequestResolution.error
- ? pullRequestResolution.error instanceof Error
- ? pullRequestResolution.error.message
- : `Failed to resolve ${terminology.singular}.`
+ ? pullRequestResolution.error
: preparePullRequestThreadAction.error instanceof Error
? preparePullRequestThreadAction.error.message
: preparePullRequestThreadAction.error
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index f3f163b017d..8a4c157cd12 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -60,11 +60,10 @@ import {
type SidebarThreadPreviewCount,
type SidebarThreadSortOrder,
} from "@t3tools/contracts/settings";
-import { usePrimaryEnvironmentId } from "../environments/primary";
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { isTerminalFocused } from "../lib/terminalFocus";
-import { isMacPlatform, newCommandId } from "../lib/utils";
+import { isMacPlatform } from "../lib/utils";
import {
selectProjectByRef,
selectProjectsAcrossEnvironments,
@@ -74,7 +73,7 @@ import {
useStore,
} from "../store";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
-import { useThreadRunningTerminalIds } from "../terminalSessionState";
+import { useWebThreadRunningTerminalIds as useThreadRunningTerminalIds } from "../connection/webTerminalSessions";
import { useUiStateStore } from "../uiStateStore";
import {
resolveShortcutCommand,
@@ -90,9 +89,10 @@ import { useVcsStatus } from "../lib/vcsStatusState";
import { readLocalApi } from "../localApi";
import { useComposerDraftStore } from "../composerDraftStore";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
-import { retainThreadDetailSubscription } from "../environments/runtime/service";
import { useThreadActions } from "../hooks/useThreadActions";
+import { useWebActions, useWebEnvironmentThread } from "../connection/useWebEnvironmentData";
+import { useWebEnvironments, useWebPrimaryEnvironment } from "../connection/useWebEnvironments";
import {
buildThreadRouteParams,
resolveThreadRouteRef,
@@ -177,7 +177,6 @@ import { sortThreads } from "../lib/threadSort";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { CommandDialogTrigger } from "./ui/command";
-import { readEnvironmentApi } from "../environmentApi";
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
import { useServerKeybindings } from "../rpc/serverState";
import {
@@ -186,10 +185,6 @@ import {
getProjectOrderKey,
selectProjectGroupingSettings,
} from "../logicalProject";
-import {
- useSavedEnvironmentRegistryStore,
- useSavedEnvironmentRuntimeStore,
-} from "../environments/runtime";
import type { SidebarThreadSummary } from "../types";
import {
buildPhysicalToLogicalProjectKeyMap,
@@ -218,6 +213,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record =
separate: "Keep separate",
};
+function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
+ useWebEnvironmentThread(threadRef.environmentId, threadRef.threadId);
+ return null;
+}
+
function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
return Math.min(
MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
@@ -347,18 +347,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
environmentId: thread.environmentId,
threadId: thread.id,
});
- const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const { environments } = useWebEnvironments();
+ const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null;
const isRemoteThread =
primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
- const remoteEnvLabel = useSavedEnvironmentRuntimeStore(
- (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null,
- );
- const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore(
- (s) => s.byId[thread.environmentId]?.label ?? null,
- );
- const threadEnvironmentLabel = isRemoteThread
- ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote")
- : null;
+ const remoteEnvLabel =
+ environments.find((environment) => environment.environmentId === thread.environmentId)?.label ??
+ null;
+ const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;
// For grouped projects, the thread may belong to a different environment
// than the representative project. Look up the thread's own project cwd
// so git status (and thus PR detection) queries the correct path.
@@ -943,6 +939,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
(settings) => settings.defaultThreadEnvMode,
);
const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
+ const actions = useWebActions();
const { updateSettings } = useUpdateSettings();
const sidebarThreadPreviewCount = useSettings(
(settings) => settings.sidebarThreadPreviewCount,
@@ -1297,19 +1294,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}
draftStore.clearProjectDraftThreadId(memberProjectRef);
- const projectApi = readEnvironmentApi(member.environmentId);
- if (!projectApi) {
- throw new Error("Project API unavailable.");
- }
-
- await projectApi.orchestration.dispatchCommand({
- type: "project.delete",
- commandId: newCommandId(),
- projectId: member.id,
- ...(options.force === true ? { force: true } : {}),
+ await actions.projects.delete({
+ environmentId: member.environmentId,
+ input: {
+ projectId: member.id,
+ ...(options.force === true ? { force: true } : {}),
+ },
});
},
- [],
+ [actions.projects],
);
const handleRemoveProject = useCallback(
@@ -1789,17 +1782,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
finishRename();
return;
}
- const api = readEnvironmentApi(threadRef.environmentId);
- if (!api) {
- finishRename();
- return;
- }
try {
- await api.orchestration.dispatchCommand({
- type: "thread.meta.update",
- commandId: newCommandId(),
- threadId: threadRef.threadId,
- title: trimmed,
+ await actions.threads.updateMetadata({
+ environmentId: threadRef.environmentId,
+ input: {
+ threadId: threadRef.threadId,
+ title: trimmed,
+ },
});
} catch (error) {
toastManager.add(
@@ -1812,7 +1801,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}
finishRename();
},
- [],
+ [actions.threads],
);
const closeProjectRenameDialog = useCallback(() => {
@@ -1839,24 +1828,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
return;
}
- const api = readEnvironmentApi(projectRenameTarget.environmentId);
- if (!api) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to rename project",
- description: "Project API unavailable.",
- }),
- );
- return;
- }
-
try {
- await api.orchestration.dispatchCommand({
- type: "project.meta.update",
- commandId: newCommandId(),
- projectId: projectRenameTarget.id,
- title: trimmed,
+ await actions.projects.update({
+ environmentId: projectRenameTarget.environmentId,
+ input: {
+ projectId: projectRenameTarget.id,
+ title: trimmed,
+ },
});
closeProjectRenameDialog();
} catch (error) {
@@ -1868,7 +1846,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}),
);
}
- }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);
+ }, [actions.projects, closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);
const closeProjectGroupingDialog = useCallback(() => {
setProjectGroupingTarget(null);
@@ -2825,9 +2803,15 @@ export default function Sidebar() {
const platform = navigator.platform;
const shortcutModifiers = useShortcutModifierState();
const modelPickerOpen = useModelPickerOpen();
- const primaryEnvironmentId = usePrimaryEnvironmentId();
- const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
- const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
+ const { environments } = useWebEnvironments();
+ const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null;
+ const environmentLabelById = useMemo(
+ () =>
+ new Map(
+ environments.map((environment) => [environment.environmentId, environment.label] as const),
+ ),
+ [environments],
+ );
const orderedProjects = useMemo(() => {
return orderItemsByPreferredIds({
items: projects,
@@ -2861,19 +2845,9 @@ export default function Sidebar() {
projects: orderedProjects,
settings: projectGroupingSettings,
primaryEnvironmentId,
- resolveEnvironmentLabel: (environmentId) => {
- const rt = savedEnvironmentRuntimeById[environmentId];
- const saved = savedEnvironmentRegistry[environmentId];
- return rt?.descriptor?.label ?? saved?.label ?? null;
- },
+ resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
});
- }, [
- orderedProjects,
- projectGroupingSettings,
- primaryEnvironmentId,
- savedEnvironmentRegistry,
- savedEnvironmentRuntimeById,
- ]);
+ }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
const sidebarProjectByKey = useMemo(
() => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
@@ -3179,18 +3153,6 @@ export default function Sidebar() {
[prewarmedSidebarThreadKeys],
);
- useEffect(() => {
- const releases = prewarmedSidebarThreadRefs.map((ref) =>
- retainThreadDetailSubscription(ref.environmentId, ref.threadId),
- );
-
- return () => {
- for (const release of releases) {
- release();
- }
- };
- }, [prewarmedSidebarThreadRefs]);
-
useEffect(() => {
updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow);
}, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]);
@@ -3415,6 +3377,9 @@ export default function Sidebar() {
return (
<>
+ {prewarmedSidebarThreadRefs.map((threadRef) => (
+
+ ))}
{isOnSettings ? (
diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx
index 2423799ac37..89b8b9d7b40 100644
--- a/apps/web/src/components/ThreadStatusIndicators.tsx
+++ b/apps/web/src/components/ThreadStatusIndicators.tsx
@@ -2,14 +2,10 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/clien
import type { VcsStatusResult } from "@t3tools/contracts";
import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
import { useMemo } from "react";
-import { usePrimaryEnvironmentId } from "../environments/primary";
-import {
- useSavedEnvironmentRegistryStore,
- useSavedEnvironmentRuntimeStore,
-} from "../environments/runtime";
+import { useWebEnvironments, useWebPrimaryEnvironment } from "../connection/useWebEnvironments";
import { useVcsStatus } from "../lib/vcsStatusState";
import { type AppState, selectProjectByRef, useStore } from "../store";
-import { useThreadRunningTerminalIds } from "../terminalSessionState";
+import { useWebThreadRunningTerminalIds as useThreadRunningTerminalIds } from "../connection/webTerminalSessions";
import { useUiStateStore } from "../uiStateStore";
import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
@@ -199,18 +195,14 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
environmentId: thread.environmentId,
threadId: thread.id,
});
- const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const { environments } = useWebEnvironments();
+ const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null;
const isRemoteThread =
primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
- const remoteEnvLabel = useSavedEnvironmentRuntimeStore(
- (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null,
- );
- const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore(
- (state) => state.byId[thread.environmentId]?.label ?? null,
- );
- const threadEnvironmentLabel = isRemoteThread
- ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote")
- : null;
+ const remoteEnvLabel =
+ environments.find((environment) => environment.environmentId === thread.environmentId)?.label ??
+ null;
+ const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;
const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
if (!terminalStatus && !isRemoteThread) {
diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx
index 56482c44e8e..3e770927c2b 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx
@@ -1,7 +1,7 @@
import "../index.css";
import { scopeThreadRef } from "@t3tools/client-runtime";
-import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts";
+import { ThreadId } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";
@@ -10,26 +10,34 @@ const {
terminalDisposeSpy,
fitAddonFitSpy,
fitAddonLoadSpy,
- environmentApiById,
- readEnvironmentApiMock,
+ terminalControllerByEnvironmentId,
+ useWebTerminalControllerMock,
readLocalApiMock,
} = vi.hoisted(() => ({
terminalConstructorSpy: vi.fn(),
terminalDisposeSpy: vi.fn(),
fitAddonFitSpy: vi.fn(),
fitAddonLoadSpy: vi.fn(),
- environmentApiById: new Map<
+ terminalControllerByEnvironmentId: new Map<
string,
{
- terminal: {
- open: ReturnType;
- attach: ReturnType;
- write: ReturnType;
- resize: ReturnType;
+ session: {
+ summary: null;
+ buffer: string;
+ status: "running";
+ error: null;
+ hasRunningSubprocess: false;
+ updatedAt: null;
+ version: number;
};
+ write: ReturnType;
+ resize: ReturnType;
+ clear: ReturnType;
+ restart: ReturnType;
+ close: ReturnType;
}
>(),
- readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
+ useWebTerminalControllerMock: vi.fn(),
readLocalApiMock: vi.fn<
() =>
| {
@@ -118,8 +126,23 @@ vi.mock("@xterm/xterm", () => ({
},
}));
-vi.mock("~/environmentApi", () => ({
- readEnvironmentApi: readEnvironmentApiMock,
+vi.mock("../connection/webTerminalSessions", () => ({
+ useWebTerminalController: (input: { environmentId: string }) => {
+ useWebTerminalControllerMock(input);
+ const controller = terminalControllerByEnvironmentId.get(input.environmentId);
+ if (controller === undefined) {
+ throw new Error(`Missing test terminal controller for ${input.environmentId}`);
+ }
+ return controller;
+ },
+}));
+
+vi.mock("../connection/useWebEnvironmentData", () => ({
+ useWebServerConfig: () => ({
+ data: { availableEditors: [] },
+ error: null,
+ isLoading: false,
+ }),
}));
vi.mock("~/localApi", () => ({
@@ -133,37 +156,22 @@ import { TerminalViewport } from "./ThreadTerminalDrawer";
const THREAD_ID = ThreadId.make("thread-terminal-browser");
-function createEnvironmentApi() {
- const snapshot = {
- threadId: THREAD_ID,
- terminalId: "term-1",
- cwd: "/repo/project",
- worktreePath: null,
- status: "running" as const,
- pid: 123,
- history: "",
- exitCode: null,
- exitSignal: null,
- label: "Terminal 1",
- updatedAt: "2026-04-07T00:00:00.000Z",
- };
-
+function createTerminalController() {
return {
- terminal: {
- open: vi.fn(async () => snapshot),
- attach: vi.fn(
- (
- _input: unknown,
- listener: (event: TerminalAttachStreamEvent) => void,
- _options?: unknown,
- ) => {
- listener({ type: "snapshot", snapshot });
- return vi.fn();
- },
- ),
- write: vi.fn(async () => undefined),
- resize: vi.fn(async () => undefined),
+ session: {
+ summary: null,
+ buffer: "",
+ status: "running" as const,
+ error: null,
+ hasRunningSubprocess: false as const,
+ updatedAt: null,
+ version: 1,
},
+ write: vi.fn(async () => undefined),
+ resize: vi.fn(async () => undefined),
+ clear: vi.fn(async () => undefined),
+ restart: vi.fn(async () => undefined),
+ close: vi.fn(async () => undefined),
};
}
@@ -239,8 +247,8 @@ async function mountTerminalViewport(props: {
describe("TerminalViewport", () => {
afterEach(() => {
- environmentApiById.clear();
- readEnvironmentApiMock.mockClear();
+ terminalControllerByEnvironmentId.clear();
+ useWebTerminalControllerMock.mockClear();
readLocalApiMock.mockClear();
terminalConstructorSpy.mockClear();
terminalDisposeSpy.mockClear();
@@ -248,26 +256,8 @@ describe("TerminalViewport", () => {
fitAddonLoadSpy.mockClear();
});
- it("does not create a terminal when APIs are unavailable", async () => {
- readEnvironmentApiMock.mockReturnValueOnce(undefined);
- readLocalApiMock.mockReturnValueOnce(undefined);
-
- const mounted = await mountTerminalViewport({
- threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
- });
-
- try {
- await vi.waitFor(() => {
- expect(terminalConstructorSpy).not.toHaveBeenCalled();
- });
- } finally {
- await mounted.cleanup();
- }
- });
-
- it("renders and attaches the terminal without the desktop local API", async () => {
- const environment = createEnvironmentApi();
- environmentApiById.set("environment-a", environment);
+ it("renders the terminal through the shared terminal controller without the desktop API", async () => {
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
readLocalApiMock.mockReturnValueOnce(undefined);
const mounted = await mountTerminalViewport({
@@ -276,7 +266,9 @@ describe("TerminalViewport", () => {
try {
await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(useWebTerminalControllerMock).toHaveBeenCalledWith(
+ expect.objectContaining({ environmentId: "environment-a" }),
+ );
});
expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
} finally {
@@ -285,8 +277,7 @@ describe("TerminalViewport", () => {
});
it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => {
- const environment = createEnvironmentApi();
- environmentApiById.set("environment-a", environment);
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
fitAddonFitSpy.mockImplementationOnce(() => {
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')");
});
@@ -297,9 +288,8 @@ describe("TerminalViewport", () => {
try {
await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
});
- expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
expect(fitAddonFitSpy).toHaveBeenCalled();
} finally {
await mounted.cleanup();
@@ -307,10 +297,8 @@ describe("TerminalViewport", () => {
});
it("reattaches the terminal when the scoped thread reference changes", async () => {
- const environmentA = createEnvironmentApi();
- const environmentB = createEnvironmentApi();
- environmentApiById.set("environment-a", environmentA);
- environmentApiById.set("environment-b", environmentB);
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
+ terminalControllerByEnvironmentId.set("environment-b", createTerminalController());
const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
@@ -318,7 +306,7 @@ describe("TerminalViewport", () => {
try {
await vi.waitFor(() => {
- expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
});
await mounted.rerender({
@@ -326,17 +314,19 @@ describe("TerminalViewport", () => {
});
await vi.waitFor(() => {
- expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(useWebTerminalControllerMock).toHaveBeenCalledWith(
+ expect.objectContaining({ environmentId: "environment-b" }),
+ );
});
expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(2);
} finally {
await mounted.cleanup();
}
});
it("does not reattach the terminal when the scoped thread reference values stay the same", async () => {
- const environment = createEnvironmentApi();
- environmentApiById.set("environment-a", environment);
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
@@ -344,16 +334,14 @@ describe("TerminalViewport", () => {
try {
await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
});
await mounted.rerender({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
});
- await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
- });
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
expect(terminalDisposeSpy).not.toHaveBeenCalled();
} finally {
await mounted.cleanup();
@@ -361,8 +349,7 @@ describe("TerminalViewport", () => {
});
it("does not reattach when runtime env contents are unchanged but object identity changes", async () => {
- const environment = createEnvironmentApi();
- environmentApiById.set("environment-a", environment);
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
@@ -371,7 +358,7 @@ describe("TerminalViewport", () => {
try {
await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
});
await mounted.rerender({
@@ -379,9 +366,7 @@ describe("TerminalViewport", () => {
runtimeEnv: { T3: "1", PATH: "/usr/bin" },
});
- await vi.waitFor(() => {
- expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
- });
+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
expect(terminalDisposeSpy).not.toHaveBeenCalled();
} finally {
await mounted.cleanup();
@@ -389,8 +374,7 @@ describe("TerminalViewport", () => {
});
it("uses the drawer surface colors for the terminal theme", async () => {
- const environment = createEnvironmentApi();
- environmentApiById.set("environment-a", environment);
+ terminalControllerByEnvironmentId.set("environment-a", createTerminalController());
const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index 9e57cbe60e1..fda7ebbec38 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -3,8 +3,6 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci
import {
type ResolvedKeybindingsConfig,
type ScopedThreadRef,
- type TerminalAttachStreamEvent,
- type TerminalSessionSnapshot,
type ThreadId,
} from "@t3tools/contracts";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
@@ -21,7 +19,7 @@ import {
} from "react";
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { type TerminalContextSelection } from "~/lib/terminalContext";
-import { openInPreferredEditor } from "../editorPreferences";
+import { useOpenInPreferredEditor } from "../editorPreferences";
import {
collectWrappedTerminalLinkLine,
extractTerminalLinks,
@@ -45,9 +43,9 @@ import {
MAX_TERMINALS_PER_GROUP,
type ThreadTerminalGroup,
} from "../types";
-import { readEnvironmentApi } from "~/environmentApi";
import { readLocalApi } from "~/localApi";
-import { attachTerminalSession } from "../terminalSessionState";
+import { useWebTerminalController } from "../connection/webTerminalSessions";
+import { useWebServerConfig } from "../connection/useWebEnvironmentData";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
@@ -68,10 +66,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void {
terminal.write(`\r\n[terminal] ${message}\r\n`);
}
-function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void {
+function writeTerminalBuffer(terminal: Terminal, buffer: string): void {
terminal.write("\u001bc");
- if (snapshot.history.length > 0) {
- terminal.write(snapshot.history);
+ if (buffer.length > 0) {
+ terminal.write(buffer);
}
}
@@ -294,6 +292,12 @@ export function TerminalViewport({
const terminalRef = useRef(null);
const fitAddonRef = useRef(null);
const environmentId = threadRef.environmentId;
+ const serverConfig = useWebServerConfig(environmentId);
+ const openInPreferredEditor = useOpenInPreferredEditor(
+ environmentId,
+ serverConfig.data?.availableEditors ?? [],
+ );
+ const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target));
const hasHandledExitRef = useRef(false);
const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
const selectionGestureActiveRef = useRef(false);
@@ -309,6 +313,20 @@ export function TerminalViewport({
onAddTerminalContext(selection);
});
const readTerminalLabel = useEffectEvent(() => terminalLabel);
+ const terminalController = useWebTerminalController({
+ environmentId,
+ terminal: {
+ threadId,
+ terminalId,
+ cwd,
+ ...(worktreePath !== undefined ? { worktreePath } : {}),
+ ...(runtimeEnv ? { env: runtimeEnv } : {}),
+ },
+ });
+ const writeTerminal = useEffectEvent(terminalController.write);
+ const resizeTerminal = useEffectEvent(terminalController.resize);
+ const readTerminalSession = useEffectEvent(() => terminalController.session);
+ const previousSessionRef = useRef(terminalController.session);
useEffect(() => {
keybindingsRef.current = keybindings;
@@ -318,10 +336,7 @@ export function TerminalViewport({
const mount = containerRef.current;
if (!mount) return;
- let disposed = false;
- const api = readEnvironmentApi(environmentId);
const localApi = readLocalApi();
- if (!api) return;
const fitAddon = new FitAddon();
const terminal = new Terminal({
@@ -338,6 +353,13 @@ export function TerminalViewport({
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
+ previousSessionRef.current = {
+ ...readTerminalSession(),
+ buffer: "",
+ status: "closed",
+ error: null,
+ version: 0,
+ };
const clearSelectionAction = () => {
selectionActionRequestIdRef.current += 1;
@@ -422,7 +444,7 @@ export function TerminalViewport({
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
try {
- await api.terminal.write({ threadId, terminalId, data });
+ await writeTerminal(data);
} catch (error) {
writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);
}
@@ -502,12 +524,15 @@ export function TerminalViewport({
const latestTerminal = terminalRef.current;
if (!latestTerminal) return;
- if (!localApi) {
- writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
- return;
- }
if (match.kind === "url") {
+ if (!localApi) {
+ writeSystemMessage(
+ latestTerminal,
+ "Opening links is unavailable in this browser.",
+ );
+ return;
+ }
void localApi.shell.openExternal(match.text).catch((error: unknown) => {
writeSystemMessage(
latestTerminal,
@@ -518,7 +543,7 @@ export function TerminalViewport({
}
const target = resolvePathLinkTarget(match.text, cwd);
- void openInPreferredEditor(localApi, target).catch((error) => {
+ void openTerminalPath(target).catch((error) => {
writeSystemMessage(
latestTerminal,
error instanceof Error ? error.message : "Unable to open path",
@@ -531,14 +556,9 @@ export function TerminalViewport({
});
const inputDisposable = terminal.onData((data) => {
- void api.terminal
- .write({ threadId, terminalId, data })
- .catch((err) =>
- writeSystemMessage(
- terminal,
- err instanceof Error ? err.message : "Terminal write failed",
- ),
- );
+ void writeTerminal(data).catch((err) =>
+ writeSystemMessage(terminal, err instanceof Error ? err.message : "Terminal write failed"),
+ );
});
const selectionDisposable = terminal.onSelectionChange(() => {
@@ -584,107 +604,6 @@ export function TerminalViewport({
attributeFilter: ["class", "style"],
});
- const applyAttachEvent = (event: TerminalAttachStreamEvent) => {
- const activeTerminal = terminalRef.current;
- if (!activeTerminal) {
- return;
- }
-
- if (event.type === "activity") {
- return;
- }
-
- if (event.type === "snapshot") {
- hasHandledExitRef.current = false;
- clearSelectionAction();
- writeTerminalSnapshot(activeTerminal, event.snapshot);
- return;
- }
-
- if (event.type === "output") {
- activeTerminal.write(event.data);
- clearSelectionAction();
- return;
- }
-
- if (event.type === "restarted") {
- hasHandledExitRef.current = false;
- clearSelectionAction();
- writeTerminalSnapshot(activeTerminal, event.snapshot);
- return;
- }
-
- if (event.type === "cleared") {
- clearSelectionAction();
- activeTerminal.clear();
- activeTerminal.write("\u001bc");
- return;
- }
-
- if (event.type === "error") {
- writeSystemMessage(activeTerminal, event.message);
- return;
- }
-
- if (event.type === "closed") {
- writeSystemMessage(activeTerminal, "Terminal closed");
- } else {
- const details = [
- typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
- typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
- ]
- .filter((value): value is string => value !== null)
- .join(", ");
- writeSystemMessage(
- activeTerminal,
- details.length > 0 ? `Process exited (${details})` : "Process exited",
- );
- }
-
- if (hasHandledExitRef.current) {
- return;
- }
- hasHandledExitRef.current = true;
- window.setTimeout(() => {
- if (!hasHandledExitRef.current) {
- return;
- }
- handleSessionExited();
- }, 0);
- };
- let unsubscribeAttach: (() => void) | null = null;
- const attachTerminal = () => {
- const activeTerminal = terminalRef.current;
- const activeFitAddon = fitAddonRef.current;
- if (!activeTerminal || !activeFitAddon) return;
- fitTerminalSafely(activeFitAddon);
- unsubscribeAttach = attachTerminalSession({
- environmentId,
- client: api,
- terminal: {
- threadId,
- terminalId,
- cwd,
- ...(worktreePath !== undefined ? { worktreePath } : {}),
- cols: activeTerminal.cols,
- rows: activeTerminal.rows,
- ...(runtimeEnv ? { env: runtimeEnv } : {}),
- },
- onEvent: (event) => {
- if (disposed) return;
- applyAttachEvent(event);
- },
- onSnapshot: () => {
- if (disposed) return;
- if (autoFocus) {
- window.requestAnimationFrame(() => {
- activeTerminal.focus();
- });
- }
- },
- });
- };
-
const fitTimer = window.setTimeout(() => {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
@@ -695,21 +614,10 @@ export function TerminalViewport({
if (wasAtBottom) {
activeTerminal.scrollToBottom();
}
- void api.terminal
- .resize({
- threadId,
- terminalId,
- cols: activeTerminal.cols,
- rows: activeTerminal.rows,
- })
- .catch(() => undefined);
+ void resizeTerminal(activeTerminal.cols, activeTerminal.rows).catch(() => undefined);
}, 30);
- attachTerminal();
return () => {
- disposed = true;
- unsubscribeAttach?.();
- unsubscribeAttach = null;
window.clearTimeout(fitTimer);
inputDisposable.dispose();
selectionDisposable.dispose();
@@ -729,6 +637,66 @@ export function TerminalViewport({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
+ useEffect(() => {
+ const terminal = terminalRef.current;
+ if (!terminal) {
+ previousSessionRef.current = terminalController.session;
+ return;
+ }
+
+ const previous = previousSessionRef.current;
+ const current = terminalController.session;
+ if (current.version === previous.version) {
+ return;
+ }
+
+ if (
+ current.buffer.length >= previous.buffer.length &&
+ current.buffer.startsWith(previous.buffer)
+ ) {
+ terminal.write(current.buffer.slice(previous.buffer.length));
+ } else {
+ writeTerminalBuffer(terminal, current.buffer);
+ }
+ terminal.clearSelection();
+
+ if (current.error !== null && current.error !== previous.error) {
+ writeSystemMessage(terminal, current.error);
+ }
+
+ if (current.status === "running") {
+ hasHandledExitRef.current = false;
+ } else if (
+ (current.status === "closed" || current.status === "exited") &&
+ current.status !== previous.status &&
+ !hasHandledExitRef.current
+ ) {
+ hasHandledExitRef.current = true;
+ writeSystemMessage(
+ terminal,
+ current.status === "closed" ? "Terminal closed" : "Process exited",
+ );
+ window.setTimeout(() => {
+ if (hasHandledExitRef.current) {
+ handleSessionExited();
+ }
+ }, 0);
+ }
+
+ if (previous.version === 0 && autoFocus) {
+ window.requestAnimationFrame(() => {
+ terminal.focus();
+ });
+ }
+ previousSessionRef.current = current;
+ }, [
+ autoFocus,
+ terminalController.session.buffer,
+ terminalController.session.error,
+ terminalController.session.status,
+ terminalController.session.version,
+ ]);
+
useEffect(() => {
if (!autoFocus) return;
const terminal = terminalRef.current;
@@ -742,24 +710,16 @@ export function TerminalViewport({
}, [autoFocus, focusRequestId]);
useEffect(() => {
- const api = readEnvironmentApi(environmentId);
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
- if (!api || !terminal || !fitAddon) return;
+ if (!terminal || !fitAddon) return;
const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
const frame = window.requestAnimationFrame(() => {
fitTerminalSafely(fitAddon);
if (wasAtBottom) {
terminal.scrollToBottom();
}
- void api.terminal
- .resize({
- threadId,
- terminalId,
- cols: terminal.cols,
- rows: terminal.rows,
- })
- .catch(() => undefined);
+ void resizeTerminal(terminal.cols, terminal.rows).catch(() => undefined);
});
return () => {
window.cancelAnimationFrame(frame);
diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
deleted file mode 100644
index a9673a96ee9..00000000000
--- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import type { WsConnectionStatus } from "../rpc/wsConnectionState";
-import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface";
-
-function makeStatus(overrides: Partial = {}): WsConnectionStatus {
- return {
- attemptCount: 0,
- closeCode: null,
- closeReason: null,
- connectionLabel: null,
- connectedAt: null,
- disconnectedAt: null,
- hasConnected: false,
- lastError: null,
- lastErrorAt: null,
- nextRetryAt: null,
- online: true,
- phase: "idle",
- reconnectAttemptCount: 0,
- reconnectMaxAttempts: 8,
- reconnectPhase: "idle",
- socketUrl: null,
- ...overrides,
- };
-}
-
-describe("WebSocketConnectionSurface.logic", () => {
- it("forces reconnect on online when the app was offline", () => {
- expect(
- shouldAutoReconnect(
- makeStatus({
- disconnectedAt: "2026-04-03T20:00:00.000Z",
- online: false,
- phase: "disconnected",
- }),
- "online",
- ),
- ).toBe(true);
- });
-
- it("forces reconnect on focus only for previously connected disconnected states", () => {
- expect(
- shouldAutoReconnect(
- makeStatus({
- hasConnected: true,
- online: true,
- phase: "disconnected",
- reconnectAttemptCount: 3,
- reconnectPhase: "waiting",
- }),
- "focus",
- ),
- ).toBe(true);
-
- expect(
- shouldAutoReconnect(
- makeStatus({
- hasConnected: false,
- online: true,
- phase: "disconnected",
- reconnectAttemptCount: 1,
- reconnectPhase: "waiting",
- }),
- "focus",
- ),
- ).toBe(false);
- });
-
- it("forces reconnect on focus for exhausted reconnect loops", () => {
- expect(
- shouldAutoReconnect(
- makeStatus({
- hasConnected: true,
- online: true,
- phase: "disconnected",
- reconnectAttemptCount: 8,
- reconnectPhase: "exhausted",
- }),
- "focus",
- ),
- ).toBe(true);
- });
-
- it("restarts a stalled reconnect window after the scheduled retry time passes", () => {
- expect(
- shouldRestartStalledReconnect(
- makeStatus({
- hasConnected: true,
- nextRetryAt: "2026-04-03T20:00:01.000Z",
- online: true,
- phase: "disconnected",
- reconnectAttemptCount: 3,
- reconnectPhase: "waiting",
- }),
- "2026-04-03T20:00:01.000Z",
- ),
- ).toBe(true);
-
- expect(
- shouldRestartStalledReconnect(
- makeStatus({
- hasConnected: true,
- nextRetryAt: "2026-04-03T20:00:01.000Z",
- online: true,
- phase: "disconnected",
- reconnectAttemptCount: 3,
- reconnectPhase: "attempting",
- }),
- "2026-04-03T20:00:01.000Z",
- ),
- ).toBe(false);
- });
-});
diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx
deleted file mode 100644
index b54bd865c8b..00000000000
--- a/apps/web/src/components/WebSocketConnectionSurface.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
-
-import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState";
-import {
- getWsConnectionStatus,
- getWsConnectionUiState,
- setBrowserOnlineStatus,
- type WsConnectionStatus,
- type WsConnectionUiState,
- useWsConnectionStatus,
- WS_RECONNECT_MAX_ATTEMPTS,
-} from "../rpc/wsConnectionState";
-import { stackedThreadToast, toastManager } from "./ui/toast";
-import { getPrimaryEnvironmentConnection } from "../environments/runtime";
-
-const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
-type WsAutoReconnectTrigger = "focus" | "online";
-
-const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, {
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- month: "short",
- second: "2-digit",
-});
-
-function formatConnectionMoment(isoDate: string | null): string | null {
- if (!isoDate) {
- return null;
- }
-
- return connectionTimeFormatter.format(new Date(isoDate));
-}
-
-function formatRetryCountdown(nextRetryAt: string, nowMs: number): string {
- const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs);
- return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`;
-}
-
-function describeOfflineToast(): string {
- return "WebSocket disconnected. Waiting for network.";
-}
-
-function formatReconnectAttemptLabel(status: WsConnectionStatus): string {
- const reconnectAttempt = Math.max(
- 1,
- Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS),
- );
- return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`;
-}
-
-function describeExhaustedToast(): string {
- return "Retries exhausted trying to reconnect";
-}
-
-function getConnectionDisplayName(status: WsConnectionStatus): string {
- return status.connectionLabel?.trim() || "T3 Server";
-}
-
-function buildReconnectTitle(status: WsConnectionStatus): string {
- return `Disconnected from ${getConnectionDisplayName(status)}`;
-}
-
-function buildRecoveredTitle(status: WsConnectionStatus): string {
- return `Reconnected to ${getConnectionDisplayName(status)}`;
-}
-
-function describeRecoveredToast(
- previousDisconnectedAt: string | null,
- connectedAt: string | null,
-): string {
- const reconnectedAtLabel = formatConnectionMoment(connectedAt);
- const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt);
-
- if (disconnectedAtLabel && reconnectedAtLabel) {
- return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`;
- }
-
- if (reconnectedAtLabel) {
- return `Connection restored at ${reconnectedAtLabel}.`;
- }
-
- return "Connection restored.";
-}
-
-function describeSlowRpcAckToast(requests: ReadonlyArray): string {
- const count = requests.length;
- const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);
-
- return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;
-}
-
-function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) {
- return (
-
- );
-}
-
-export function shouldAutoReconnect(
- status: WsConnectionStatus,
- trigger: WsAutoReconnectTrigger,
-): boolean {
- const uiState = getWsConnectionUiState(status);
-
- if (trigger === "online") {
- return (
- uiState === "offline" ||
- uiState === "reconnecting" ||
- uiState === "error" ||
- status.reconnectPhase === "exhausted"
- );
- }
-
- return (
- status.online &&
- status.hasConnected &&
- (uiState === "reconnecting" || status.reconnectPhase === "exhausted")
- );
-}
-
-export function shouldRestartStalledReconnect(
- status: WsConnectionStatus,
- expectedNextRetryAt: string,
-): boolean {
- return (
- status.reconnectPhase === "waiting" &&
- status.nextRetryAt === expectedNextRetryAt &&
- status.online &&
- status.hasConnected
- );
-}
-
-export function WebSocketConnectionCoordinator() {
- const status = useWsConnectionStatus();
- const [nowMs, setNowMs] = useState(() => Date.now());
- const lastForcedReconnectAtRef = useRef(0);
- const toastIdRef = useRef | null>(null);
- const toastResetTimerRef = useRef(null);
- const previousUiStateRef = useRef(getWsConnectionUiState(status));
- const previousDisconnectedAtRef = useRef(status.disconnectedAt);
-
- const runReconnect = useEffectEvent((showFailureToast: boolean) => {
- if (toastResetTimerRef.current !== null) {
- window.clearTimeout(toastResetTimerRef.current);
- toastResetTimerRef.current = null;
- }
- lastForcedReconnectAtRef.current = Date.now();
- void getPrimaryEnvironmentConnection()
- .reconnect()
- .catch((error) => {
- if (!showFailureToast) {
- console.warn("Automatic WebSocket reconnect failed", { error });
- return;
- }
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Reconnect failed",
- description:
- error instanceof Error ? error.message : "Unable to restart the WebSocket.",
- data: {
- dismissAfterVisibleMs: 8_000,
- hideCopyButton: true,
- },
- }),
- );
- });
- });
- const syncBrowserOnlineStatus = useEffectEvent(() => {
- setBrowserOnlineStatus(navigator.onLine !== false);
- });
- const triggerManualReconnect = useEffectEvent(() => {
- runReconnect(true);
- });
- const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => {
- const currentStatus =
- trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus();
-
- if (!shouldAutoReconnect(currentStatus, trigger)) {
- return;
- }
- if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) {
- return;
- }
-
- runReconnect(false);
- });
-
- useEffect(() => {
- const handleOnline = () => {
- triggerAutoReconnect("online");
- };
- const handleFocus = () => {
- triggerAutoReconnect("focus");
- };
-
- syncBrowserOnlineStatus();
- window.addEventListener("online", handleOnline);
- window.addEventListener("offline", syncBrowserOnlineStatus);
- window.addEventListener("focus", handleFocus);
- return () => {
- window.removeEventListener("online", handleOnline);
- window.removeEventListener("offline", syncBrowserOnlineStatus);
- window.removeEventListener("focus", handleFocus);
- };
- }, []);
-
- useEffect(() => {
- if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) {
- return;
- }
-
- setNowMs(Date.now());
- const intervalId = window.setInterval(() => {
- setNowMs(Date.now());
- }, 1_000);
-
- return () => {
- window.clearInterval(intervalId);
- };
- }, [status.nextRetryAt, status.reconnectPhase]);
-
- useEffect(() => {
- if (
- status.reconnectPhase !== "waiting" ||
- status.nextRetryAt === null ||
- !status.online ||
- !status.hasConnected
- ) {
- return;
- }
-
- const nextRetryAt = status.nextRetryAt;
- const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500;
- const timeoutId = window.setTimeout(() => {
- const currentStatus = getWsConnectionStatus();
- if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) {
- return;
- }
-
- runReconnect(false);
- }, timeoutMs);
-
- return () => {
- window.clearTimeout(timeoutId);
- };
- }, [
- status.hasConnected,
- status.nextRetryAt,
- status.online,
- status.reconnectAttemptCount,
- status.reconnectPhase,
- ]);
-
- useEffect(() => {
- const uiState = getWsConnectionUiState(status);
- const previousUiState = previousUiStateRef.current;
- const previousDisconnectedAt = previousDisconnectedAtRef.current;
- const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting";
- const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null;
- const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted";
-
- if (
- toastResetTimerRef.current !== null &&
- (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast)
- ) {
- window.clearTimeout(toastResetTimerRef.current);
- toastResetTimerRef.current = null;
- }
-
- if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) {
- const toastPayload = shouldShowOfflineToast
- ? stackedThreadToast({
- data: {
- hideCopyButton: true,
- },
- description: describeOfflineToast(),
- timeout: 0,
- title: "Offline",
- type: "warning",
- })
- : shouldShowExhaustedToast
- ? stackedThreadToast({
- actionProps: {
- children: "Retry",
- onClick: triggerManualReconnect,
- },
- data: {
- hideCopyButton: true,
- },
- description: describeExhaustedToast(),
- timeout: 0,
- title: buildReconnectTitle(status),
- type: "error",
- })
- : stackedThreadToast({
- actionProps: {
- children: "Retry now",
- onClick: triggerManualReconnect,
- },
- data: {
- hideCopyButton: true,
- },
- description:
- status.nextRetryAt === null
- ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
- : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
- timeout: 0,
- title: buildReconnectTitle(status),
- type: "loading",
- });
-
- if (toastIdRef.current) {
- toastManager.update(toastIdRef.current, toastPayload);
- } else {
- toastIdRef.current = toastManager.add(toastPayload);
- }
- } else if (toastIdRef.current) {
- toastManager.close(toastIdRef.current);
- toastIdRef.current = null;
- }
-
- if (
- uiState === "connected" &&
- (previousUiState === "offline" || previousUiState === "reconnecting") &&
- previousDisconnectedAt !== null
- ) {
- const successToast = {
- description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt),
- title: buildRecoveredTitle(status),
- type: "success" as const,
- timeout: 0,
- data: {
- dismissAfterVisibleMs: 8_000,
- hideCopyButton: true,
- },
- };
-
- if (toastIdRef.current) {
- toastManager.update(toastIdRef.current, successToast);
- } else {
- toastIdRef.current = toastManager.add(successToast);
- }
-
- toastResetTimerRef.current = window.setTimeout(() => {
- toastIdRef.current = null;
- toastResetTimerRef.current = null;
- }, 8_250);
- }
-
- previousUiStateRef.current = uiState;
- previousDisconnectedAtRef.current = status.disconnectedAt;
- }, [nowMs, status]);
-
- useEffect(() => {
- return () => {
- if (toastResetTimerRef.current !== null) {
- window.clearTimeout(toastResetTimerRef.current);
- }
- };
- }, []);
-
- return null;
-}
-
-export function SlowRpcAckToastCoordinator() {
- const slowRequests = useSlowRpcAckRequests();
- const status = useWsConnectionStatus();
- const toastIdRef = useRef | null>(null);
-
- useEffect(() => {
- if (getWsConnectionUiState(status) !== "connected") {
- if (toastIdRef.current) {
- toastManager.close(toastIdRef.current);
- toastIdRef.current = null;
- }
- return;
- }
-
- if (slowRequests.length === 0) {
- if (toastIdRef.current) {
- toastManager.close(toastIdRef.current);
- toastIdRef.current = null;
- }
- return;
- }
-
- const nextToast = {
- data: {
- expandableContent: ,
- expandableDescriptionTrigger: true,
- expandableLabels: { collapse: "Hide requests", expand: "Show requests" },
- },
- description: describeSlowRpcAckToast(slowRequests),
- timeout: 0,
- title: "Some requests are slow",
- type: "warning" as const,
- };
-
- if (toastIdRef.current) {
- toastManager.update(toastIdRef.current, nextToast);
- } else {
- toastIdRef.current = toastManager.add(nextToast);
- }
- }, [slowRequests, status]);
-
- return null;
-}
-
-export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) {
- return children;
-}
diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx
index 65e9c6dd8eb..6dd41e82415 100644
--- a/apps/web/src/components/auth/PairingRouteSurface.tsx
+++ b/apps/web/src/components/auth/PairingRouteSurface.tsx
@@ -2,7 +2,7 @@ import type { AuthSessionState } from "@t3tools/contracts";
import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";
import { APP_DISPLAY_NAME } from "../../branding";
-import { addSavedEnvironment } from "../../environments/runtime";
+import { useWebEnvironmentActions } from "../../connection/useWebEnvironments";
import {
peekPairingTokenFromUrl,
stripPairingTokenFromUrl,
@@ -162,6 +162,7 @@ export function PairingRouteSurface({
}
export function HostedPairingRouteSurface() {
+ const { connectPairing } = useWebEnvironmentActions();
const hostedPairingRequestRef = useRef(readHostedPairingRequest());
const [status, setStatus] = useState<"pairing" | "paired" | "error">(() =>
hostedPairingRequestRef.current ? "pairing" : "error",
@@ -198,13 +199,12 @@ export function HostedPairingRouteSurface() {
tokenSubmittedRef.current = true;
try {
- const record = await addSavedEnvironment({
- label: request.label,
+ await connectPairing({
host: request.host,
pairingCode: request.token,
});
setStatus("paired");
- setMessage(`${record.label} is saved in this browser.`);
+ setMessage(`${request.label || "The environment"} is saved in this browser.`);
} catch (error) {
tokenSubmittedRef.current = false;
setStatus("error");
@@ -213,7 +213,7 @@ export function HostedPairingRouteSurface() {
`${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`,
);
}
- }, []);
+ }, [connectPairing]);
useEffect(() => {
if (submitAttemptedRef.current) {
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 1185817454d..59bab87b0d3 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -392,7 +392,7 @@ export interface ChatComposerProps {
isPreparingWorktree: boolean;
environmentUnavailable: {
readonly label: string;
- readonly connectionState: "connecting" | "disconnected" | "error";
+ readonly connectionState: "connecting" | "reconnecting" | "disconnected" | "error";
} | null;
// Pending approvals / inputs
@@ -2251,7 +2251,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? `${environmentUnavailable.label} is ${
environmentUnavailable.connectionState === "connecting"
? "connecting"
- : "disconnected"
+ : environmentUnavailable.connectionState === "reconnecting"
+ ? "reconnecting"
+ : "disconnected"
}`
: phase === "disconnected"
? "Ask for follow-up changes or attach images"
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index ac420317530..f3e88e5e01c 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -16,7 +16,7 @@ import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScr
import { Toggle } from "../ui/toggle";
import { SidebarTrigger } from "../ui/sidebar";
import { OpenInPicker } from "./OpenInPicker";
-import { usePrimaryEnvironmentId } from "../../environments/primary";
+import { useWebPrimaryEnvironment } from "../../connection/useWebEnvironments";
interface ChatHeaderProps {
activeThreadEnvironmentId: EnvironmentId;
@@ -81,7 +81,7 @@ export const ChatHeader = memo(function ChatHeader({
onToggleTerminal,
onToggleDiff,
}: ChatHeaderProps) {
- const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null;
const showOpenInPicker = shouldShowOpenInPicker({
activeProjectName,
activeThreadEnvironmentId,
@@ -126,6 +126,7 @@ export const ChatHeader = memo(function ChatHeader({
)}
{showOpenInPicker && (
) => {
const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
@@ -151,14 +151,17 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray;
openInCwd: string | null;
}) {
+ const actions = useWebActions();
const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors);
const options = useMemo(
() => resolveOptions(navigator.platform, availableEditors),
@@ -168,14 +171,19 @@ export const OpenInPicker = memo(function OpenInPicker({
const openInEditor = useCallback(
(editorId: EditorId | null) => {
- const api = readLocalApi();
- if (!api || !openInCwd) return;
+ if (!openInCwd) return;
const editor = editorId ?? preferredEditor;
if (!editor) return;
- void api.shell.openInEditor(openInCwd, editor);
+ void actions.shell.openInEditor({
+ environmentId,
+ input: {
+ cwd: openInCwd,
+ editor,
+ },
+ });
setPreferredEditor(editor);
},
- [preferredEditor, openInCwd, setPreferredEditor],
+ [actions.shell, environmentId, openInCwd, preferredEditor, setPreferredEditor],
);
const openFavoriteEditorShortcutLabel = useMemo(
@@ -185,17 +193,22 @@ export const OpenInPicker = memo(function OpenInPicker({
useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
- const api = readLocalApi();
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
- if (!api || !openInCwd) return;
+ if (!openInCwd) return;
if (!preferredEditor) return;
e.preventDefault();
- void api.shell.openInEditor(openInCwd, preferredEditor);
+ void actions.shell.openInEditor({
+ environmentId,
+ input: {
+ cwd: openInCwd,
+ editor: preferredEditor,
+ },
+ });
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
- }, [preferredEditor, keybindings, openInCwd]);
+ }, [actions.shell, environmentId, keybindings, openInCwd, preferredEditor]);
return (
diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx
index e53ee93b913..bd18c019b9f 100644
--- a/apps/web/src/components/chat/ProposedPlanCard.tsx
+++ b/apps/web/src/components/chat/ProposedPlanCard.tsx
@@ -25,7 +25,7 @@ import {
DialogTitle,
} from "../ui/dialog";
import { stackedThreadToast, toastManager } from "../ui/toast";
-import { readEnvironmentApi } from "~/environmentApi";
+import { useWebActions } from "~/connection/useWebEnvironmentData";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
export const ProposedPlanCard = memo(function ProposedPlanCard({
@@ -43,6 +43,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [savePath, setSavePath] = useState("");
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
+ const actions = useWebActions();
const { copyToClipboard, isCopied } = useCopyToClipboard({
onError: (error) => {
toastManager.add(
@@ -89,9 +90,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
};
const handleSaveToWorkspace = () => {
- const api = readEnvironmentApi(environmentId);
const relativePath = savePath.trim();
- if (!api || !workspaceRoot) {
+ if (!workspaceRoot) {
return;
}
if (!relativePath) {
@@ -103,11 +103,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
}
setIsSavingToWorkspace(true);
- void api.projects
+ void actions.projects
.writeFile({
- cwd: workspaceRoot,
- relativePath,
- contents: saveContents,
+ environmentId,
+ input: {
+ cwd: workspaceRoot,
+ relativePath,
+ contents: saveContents,
+ },
})
.then((result) => {
setIsSaveDialogOpen(false);
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
index c84eb347352..933354dcc57 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
@@ -1,5 +1,4 @@
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
-import { EnvironmentId } from "@t3tools/contracts";
import { createModelCapabilities } from "@t3tools/shared/model";
import { page, userEvent } from "vite-plus/test/browser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
@@ -19,63 +18,6 @@ import {
} from "@t3tools/contracts/settings";
import { __resetLocalApiForTests } from "../../localApi";
-// Mock the environments/runtime module to provide a mock primary environment connection
-vi.mock("../../environments/runtime", () => {
- const primaryConnection = {
- kind: "primary" as const,
- knownEnvironment: {
- id: "environment-local",
- label: "Local environment",
- source: "manual" as const,
- environmentId: EnvironmentId.make("environment-local"),
- target: {
- httpBaseUrl: "http://localhost:3000",
- wsBaseUrl: "ws://localhost:3000",
- },
- },
- environmentId: EnvironmentId.make("environment-local"),
- client: {
- server: {
- getConfig: vi.fn(),
- updateSettings: vi.fn(),
- },
- },
- ensureBootstrapped: async () => undefined,
- reconnect: async () => undefined,
- dispose: async () => undefined,
- };
-
- return {
- getEnvironmentHttpBaseUrl: () => "http://localhost:3000",
- getSavedEnvironmentRecord: () => null,
- getSavedEnvironmentRuntimeState: () => null,
- hasSavedEnvironmentRegistryHydrated: () => true,
- listSavedEnvironmentRecords: () => [],
- resetSavedEnvironmentRegistryStoreForTests: vi.fn(),
- resetSavedEnvironmentRuntimeStoreForTests: vi.fn(),
- resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) =>
- new URL(path, "http://localhost:3000").toString(),
- waitForSavedEnvironmentRegistryHydration: async () => undefined,
- addSavedEnvironment: vi.fn(),
- disconnectSavedEnvironment: vi.fn(),
- ensureEnvironmentConnectionBootstrapped: async () => undefined,
- getPrimaryEnvironmentConnection: () => primaryConnection,
- readEnvironmentConnection: () => primaryConnection,
- reconnectSavedEnvironment: vi.fn(),
- removeSavedEnvironment: vi.fn(),
- requireEnvironmentConnection: () => primaryConnection,
- resetEnvironmentServiceForTests: vi.fn(),
- startEnvironmentConnectionService: vi.fn(),
- subscribeEnvironmentConnections: () => () => {},
- useSavedEnvironmentRegistryStore: (
- selector: (state: { byId: Record }) => unknown,
- ) => selector({ byId: {} }),
- useSavedEnvironmentRuntimeStore: (
- selector: (state: { byId: Record }) => unknown,
- ) => selector({ byId: {} }),
- };
-});
-
function selectDescriptor(
id: string,
label: string,
diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx
index c2a1541b120..0d47197e1a7 100644
--- a/apps/web/src/components/settings/CloudSettings.tsx
+++ b/apps/web/src/components/settings/CloudSettings.tsx
@@ -101,7 +101,15 @@ function CloudSettingsPanelInner() {
const updatePublishAgentActivity = async (enabled: boolean) => {
setIsUpdatingPreference(true);
try {
- await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled }));
+ if (!primaryLinkState.target) {
+ throw new Error("Local environment is not ready yet.");
+ }
+ await webRuntime.runPromise(
+ updatePrimaryCloudPreferences({
+ target: primaryLinkState.target,
+ publishAgentActivity: enabled,
+ }),
+ );
primaryLinkState.refresh();
toastManager.add({
type: "success",
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 9dab7b61fac..75bf4eeb15c 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -8,6 +8,7 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import { useAuth } from "@clerk/react";
+import { useAtomSet } from "@effect/atom-react";
import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
import {
AuthAccessReadScope,
@@ -29,9 +30,10 @@ import {
type DesktopServerExposureState,
type EnvironmentId,
} from "@t3tools/contracts";
-import { WsRpcClient } from "@t3tools/client-runtime";
+import { findErrorTraceId } from "@t3tools/client-runtime";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { cn } from "../../lib/utils";
@@ -96,39 +98,26 @@ import {
revokeServerClientSession,
revokeServerPairingLink,
isLoopbackHostname,
- usePrimaryEnvironmentId,
usePrimarySessionState,
type ServerClientSessionRecord,
type ServerPairingLinkRecord,
} from "~/environments/primary";
-import {
- type SavedEnvironmentRecord,
- type SavedEnvironmentRuntimeState,
- useSavedEnvironmentRegistryStore,
- useSavedEnvironmentRuntimeStore,
- addSavedEnvironment,
- addManagedRelayEnvironment,
- connectDesktopSshEnvironment,
- disconnectSavedEnvironment,
- getPrimaryEnvironmentConnection,
- reconnectSavedEnvironment,
- removeSavedEnvironment,
-} from "~/environments/runtime";
import { useUiStateStore } from "~/uiStateStore";
import { resolveServerConfigVersionMismatch } from "~/versionSkew";
-import { useServerConfig } from "~/rpc/serverState";
-import {
- connectManagedCloudEnvironment,
- linkPrimaryEnvironmentToCloud,
- unlinkPrimaryEnvironmentFromCloud,
-} from "~/cloud/linkEnvironment";
-import {
- refreshManagedRelayEnvironments,
- useManagedRelayEnvironments,
-} from "~/cloud/managedRelayState";
import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
-import { webRuntime } from "~/lib/runtime";
import { hasCloudPublicConfig } from "~/cloud/publicConfig";
+import {
+ linkWebPrimaryEnvironment,
+ unlinkWebPrimaryEnvironment,
+} from "~/connection/webConnectionRuntime";
+import { useWebAuthAccessChanges } from "~/connection/useWebEnvironmentData";
+import {
+ type WebEnvironmentPresentation,
+ useWebEnvironmentActions,
+ useWebEnvironments,
+ useWebPrimaryEnvironment,
+ useWebRelayEnvironmentDiscovery,
+} from "~/connection/useWebEnvironments";
const DEFAULT_TAILSCALE_SERVE_PORT = 443;
@@ -290,32 +279,7 @@ function ConnectionStatusDot({
);
}
-function getSavedBackendStatusTooltip(
- runtime: SavedEnvironmentRuntimeState | null,
- record: SavedEnvironmentRecord,
- nowMs: number,
-) {
- const connectionState = runtime?.connectionState ?? "disconnected";
-
- if (connectionState === "connected") {
- const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt;
- return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null;
- }
-
- if (connectionState === "connecting") {
- return null;
- }
-
- if (connectionState === "error") {
- return runtime?.lastError ?? "An unknown connection error occurred.";
- }
-
- return record.lastConnectedAt
- ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}`
- : "Not connected yet.";
-}
-
-function formatDesktopSshTarget(target: NonNullable): string {
+function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {
const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
return target.port ? `${authority}:${target.port}` : authority;
}
@@ -1449,54 +1413,58 @@ function NetworkAccessDescription({
}
type SavedBackendListRowProps = {
- environmentId: EnvironmentId;
+ environment: WebEnvironmentPresentation;
reconnectingEnvironmentId: EnvironmentId | null;
- disconnectingEnvironmentId: EnvironmentId | null;
removingEnvironmentId: EnvironmentId | null;
onConnect: (environmentId: EnvironmentId) => void;
- onDisconnect: (environmentId: EnvironmentId) => void;
onRemove: (environmentId: EnvironmentId) => void;
};
function SavedBackendListRow({
- environmentId,
+ environment,
reconnectingEnvironmentId,
- disconnectingEnvironmentId,
removingEnvironmentId,
onConnect,
- onDisconnect,
onRemove,
}: SavedBackendListRowProps) {
- const nowMs = useRelativeTimeTick(1_000);
- const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null);
- const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null);
-
- if (!record) {
- return null;
- }
-
- const connectionState = runtime?.connectionState ?? "disconnected";
+ const environmentId = environment.environmentId;
+ const connectionState = environment.connection.phase;
const isConnected = connectionState === "connected";
const isConnecting =
- connectionState === "connecting" || reconnectingEnvironmentId === environmentId;
- const isDisconnecting = disconnectingEnvironmentId === environmentId;
+ connectionState === "connecting" ||
+ connectionState === "reconnecting" ||
+ reconnectingEnvironmentId === environmentId;
const stateDotClassName =
connectionState === "connected"
? "bg-success"
- : connectionState === "connecting"
+ : connectionState === "connecting" || connectionState === "reconnecting"
? "bg-warning"
: connectionState === "error"
? "bg-destructive"
: "bg-muted-foreground/40";
- const descriptorLabel = runtime?.descriptor?.label ?? null;
- const displayLabel = descriptorLabel ?? record.label;
- const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);
- const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);
+ const statusTooltip =
+ connectionState === "connected"
+ ? "Connected"
+ : connectionState === "connecting"
+ ? "Connecting"
+ : connectionState === "reconnecting"
+ ? "Reconnecting"
+ : connectionState === "offline"
+ ? "Offline"
+ : connectionState === "error"
+ ? environment.connection.error
+ : "Available";
+ const errorTraceId = environment.connection.traceId;
+ const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
+ const sshTarget =
+ environment.entry.target._tag === "SshConnectionTarget" &&
+ Option.isSome(environment.entry.profile) &&
+ environment.entry.profile.value._tag === "SshConnectionProfile"
+ ? environment.entry.profile.value.target
+ : null;
const metadataBits = [
- record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,
- record.lastConnectedAt
- ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`
- : null,
+ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
+ environment.relayManaged ? "T3 Cloud" : null,
].filter((value): value is string => value !== null);
return (
@@ -1508,19 +1476,15 @@ function SavedBackendListRow({
tooltipText={statusTooltip}
dotClassName={stateDotClassName}
pingClassName={
- connectionState === "connecting" ? "bg-warning/60 duration-2000" : null
+ connectionState === "connecting" || connectionState === "reconnecting"
+ ? "bg-warning/60 duration-2000"
+ : null
}
/>
- {displayLabel}
+ {environment.label}
- {metadataBits.length > 0 || runtime?.scopes ? (
-
- {metadataBits.length > 0 ? metadataBits.join(" · ") : null}
- {metadataBits.length > 0 && runtime?.scopes ? · : null}
- {runtime?.scopes ? (
-
- ) : null}
-
+ {metadataBits.length > 0 ? (
+
@@ -1529,32 +1493,36 @@ function SavedBackendListRow({
{versionMismatch.serverVersion}.
) : null}
+ {connectionState === "error" && environment.connection.error ? (
+