One React renderer, three runtime edges
The web client keeps one route tree and environment-oriented state model across hosted, locally served, and Electron renderer deployments. Platform differences enter at history, authentication, native-host, and transport boundaries—not in a forked product model.
What this chapter resolves
- Separate the hosted, locally served, and Electron-renderer deployment choices from the shared React application.
- Trace entry, AppRoot, atom registry, router history, generated route tree, and environment-scoped session state.
- Explain why virtualized timelines and row-local structural sharing are implementation choices, not performance benchmarks.
- Locate tracing export and the browser terminal at their respective client/server authority boundaries.
The web client is not three applications. It is one React renderer deployed in three circumstances with materially different edges:
- Hosted web serves the app as a regular browser application and can connect to environments discovered or paired through the product’s connection model.
- Locally served web is the same browser application delivered by a local T3 server; the server distribution copies the already-built web bundle.
- Electron renderer loads that renderer from a file-backed shell, gains a preload bridge and Electron-only hosts, and uses hash history so route paths do not become file paths.
This is a deployment distinction, not evidence of three independent thread stores. The selected environment remains the authority for sessions, snapshots, commands, and streamed updates in every case.
import React from "react";
import ReactDOM from "react-dom/client";
import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
import "./index.css";
import { isElectron } from "./env";
import { hasCloudPublicConfig } from "./cloud/publicConfig";
import { getRouter } from "./router";
import {
syncDocumentElectronPlatformClasses,
syncDocumentWindowControlsOverlayClass,
} from "./lib/windowControlsOverlay";
import { AppRoot } from "./AppRoot";
import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
const history = isElectron ? createHashHistory() : createBrowserHistory();
const router = getRouter(history);
if (isElectron) {
syncDocumentElectronPlatformClasses(navigator.platform);
syncDocumentWindowControlsOverlayClass();
}
const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
// A failed split-chunk fetch usually means the hashed assets went stale under
// a deploy; one guarded reload picks up the fresh index.html.
let chunkLoadFailed = false;
let reloadScheduled = false;
window.addEventListener("vite:preloadError", (event) => {
chunkLoadFailed = true;
if (reloadOnceForChunkLoadError()) {
reloadScheduled = true;
event.preventDefault();
}
});
const app = <AppRoot router={router} />;
// Managed auth is cloud-only, and the Electron Clerk provider bundles the full
// clerk-js runtime. Loading only the selected runtime as a split chunk keeps
// every Clerk byte out of the startup graph for local-mode users, and keeps
// the bundled clerk-js out of the browser build entirely.
const managedAuthShellModule =
clerkPublishableKey && hasCloudPublicConfig()
? isElectron
? import("./components/clerk/ElectronManagedAuthShell")
: import("./components/clerk/BrowserManagedAuthShell")
: null;
// The index.html boot splash lives inside #root, and React's first commit
// clears it. Resolve everything that first commit needs, the selected
// managed-auth runtime and the initial route's split chunks, before
// rendering, so the splash holds until real UI paints instead of dropping to
// a blank window while chunks download.
export const startup = Promise.all([
managedAuthShellModule?.then((module) => module.default) ?? null,
router.load(),Entry establishes the small platform seam
At module load, isElectron means that the preload bridge placed
window.desktopBridge on the page. The entry then chooses TanStack Router’s hash
history for Electron and browser history otherwise, creates the router once, and
performs Electron-specific window-control class synchronization. Cloud
authentication is optional: it is enabled only when both a publishable key and
public cloud configuration are present; Electron receives its Electron Clerk
provider and passkey support, while a normal browser receives the browser provider.
In both branches, the child is the same AppRoot.
The local CLI packaging path provides another useful boundary: it packages a built web client as server static output. That says how a browser gets the assets; it does not merge the React process and the server process into one memory space.
AppRoot owns renderer-wide state, then routes
AppRoot supplies one Effect atom registry around both routed content and its
longer-lived hosts. The router renders the generated file-route tree. Preview
automation hosts, the Electron browser host, and the quit-hold overlay sit beside
the router rather than inside a route. The source comment makes the reason
concrete: Electron webviews must survive a route transition, while still sharing
the registry with the routed UI.
import { RouterProvider } from "@tanstack/react-router";
import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
/**
* Owns renderer-wide providers. The Electron browser host intentionally sits
* outside the router so its webviews survive route transitions, but it must
* share the same atom registry as routed UI.
*/
export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
<AppAtomRegistryProvider>
<RouterProvider router={router} />
<PreviewAutomationHosts />
<ElectronBrowserHost />
<QuitHoldOverlay />
</AppAtomRegistryProvider>
);
}import { createRouter, RouterHistory } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter(history: RouterHistory) {
return createRouter({
routeTree,
history,
context: {},
// Route components are split chunks (autoCodeSplitting in vite.config);
// fetching them on hover/focus intent hides the load from the first
// settings or pull-request navigation.
defaultPreload: "intent",
});
}
export type AppRouter = ReturnType<typeof getRouter>;
declare module "@tanstack/react-router" {
interface Register {The file routes divide broadly into connection/pairing, settings, usage, project
redirects, and the _chat shell. The chat subtree provides an index, a draft view,
a scoped $environmentId/$threadId view, and pull-request work. A URL therefore
names a presentation location and a scoped thread identity; it is not a substitute
for the environment session or a serialized thread snapshot.
Environment atoms keep connection multiplicity out of route components
The web layer creates one connection atom runtime, uses it to make an environment catalog and per-environment session atoms, and exposes hooks that read an environment id rather than a process-global server. A prepared connection carries the HTTP base URL for the named environment; session state is independently read from that environment’s authentication endpoint. The registry is renderer-wide and is reset only by the test helper, not on each route render.
import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections";
import { connectionAtomRuntime } from "./runtime";
export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime);import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
Atom.withLabel("web-prepared-connection:empty"),
);
export function usePreparedConnection(environmentId: EnvironmentId | null) {
return useAtomValue(
environmentId === null
? EMPTY_PREPARED_CONNECTION_ATOM
: environmentSession.preparedConnectionValueAtom(environmentId),
);
}
export function readPreparedConnection(environmentId: EnvironmentId) {
return Option.getOrNull(
appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)),
);
}
/**
* This client's authenticated session on one environment, as reported by that
* environment's `/api/auth/session` endpoint. `data` stays populated across
* SWR revalidations; `isPending` is only meaningful before the first resolve.
*/
export function useEnvironmentSessionState(environmentId: EnvironmentId) {
const result = useAtomValue(environmentSession.sessionStateAtom(environmentId));
return {
data: Option.getOrNull(AsyncResult.value(result)),
hasError: result._tag === "Failure",
isPending: result.waiting,
};This pattern matters because a route can be active while its environment is reconnecting, and a client can hold several environment presentations at once. The route tells the UI which scoped thread to request; the registry and environment atoms answer through which prepared connection it can be reached.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Hosted and locally served browsers use browser history; an Electron renderer uses hash history and Electron authentication/window integrations. All create the same router and AppRoot. AppRoot supplies an atom registry around RouterProvider, preview automation, the Electron browser host, and a quit overlay. Chat routes select environment-scoped thread state. Environment catalog and session atoms prepare connections and obtain snapshot plus live updates. Those updates feed a virtualized message timeline and memoized sidebar rows. The browser terminal attaches to a server-owned terminal session. Client tracing exports OTLP to the selected primary environment. Electron browser webviews sit outside route transitions. No arrow claims that a browser owns a PTY, that tracing proves a span was accepted, or that the diagram measures performance.
apps/web/src/main.tsx:1–61 ↗apps/web/src/AppRoot.tsx:1–23 ↗apps/web/src/router.ts:1–20 ↗apps/web/src/connection/catalog.ts:1–5 ↗apps/web/src/state/session.ts:7–41 ↗packages/client-runtime/src/state/threads.ts:534–645 ↗apps/web/src/components/chat/MessagesTimeline.tsx:570–648 ↗apps/web/src/components/chat/MessagesTimeline.tsx:2095–2113 ↗apps/web/src/components/Sidebar.tsx:2200–2252 ↗apps/web/src/components/ThreadTerminalDrawer.tsx:337–527 ↗apps/web/src/observability/clientTracing.ts:15–96 ↗The chat hot path constrains work per update
The message timeline uses LegendList, stable row ids, estimated item size, end
anchoring, and explicit visible-content maintenance. Before it reaches the list,
the row builder structurally reuses a prior row object whenever its relevant fields
are unchanged. Shared callbacks and non-row-scoped state travel through React
context so they do not need to become freshly allocated props for every row.
expandedTurnIds,
expandedWorkGroupIds,
isWorking,
activeTurnStartedAt,
turnDiffSummaries,
supportsConversationRollback,
]);
const rows = useStableRows(rawRows);
const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
null,
);
const {
target: readyCitationRequest,
positioning: citationPositioning,
onListLoad: onCitationListLoad,
alwaysRender: citationAlwaysRender,
} = useAssistantCitationTarget({
request: citationRequest,
entries: timelineEntries,
rows,
listRef,
viewport: timelineViewportElement,
historyLoading: citationHistoryLoading,
loadEarlier,
onExpandTurn: expandCitedTurn,
onManualNavigation,
});
const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
const handleAnchorReady = useCallback(
(info: { anchorIndex: number | undefined }) => {
if (anchorMessageId !== null && info.anchorIndex !== undefined) {
onAnchorReady(anchorMessageId, info.anchorIndex);
}
},
[anchorMessageId, onAnchorReady],
);
const anchoredEndSpace = useMemo(() => {
const config = resolveChatListAnchoredEndSpace(
rows,
anchorMessageId,
(row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
{ anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
);
return config ? { ...config, onReady: handleAnchorReady } : undefined;
}, [anchorMessageId, handleAnchorReady, rows]);
const timelineListFooter = useMemo(
() => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />,
[anchoredEndSpace, contentInsetEndAdjustment],
);
const measureContentOverflow = useCallback(
() =>
timelineContentOverflowsViewport(listRef.current?.getState?.(), {
composerInset: contentInsetEndAdjustment,
anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
}),
[contentInsetEndAdjustment, listRef],
);
// LegendList lays rows out from layout effects, so a read on the next frame
// sees the settled positions. One frame is shared across bursts of size
// changes.
const contentOverflowFrameRef = useRef<number | null>(null);
const cancelContentOverflowFrame = useCallback(() => {
if (contentOverflowFrameRef.current !== null) {
cancelAnimationFrame(contentOverflowFrameRef.current);
contentOverflowFrameRef.current = null;
}
}, []);
const reportContentOverflow = useCallback(() => {
if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return;
contentOverflowFrameRef.current = requestAnimationFrame(() => {
contentOverflowFrameRef.current = null;
onContentOverflowChange(measureContentOverflow());
});
}, [measureContentOverflow, onContentOverflowChange]);
useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); </span>
);
}
const failedToolIconClassName = "text-tool-error-icon/40";
/** Image icons and the gradient computer-use mark cannot take a currentColor
* tint, so failed rows using them get a trailing x instead. */
function toolIconAcceptsTint(
iconName: WorkEntryIconName,
toolIcon: ToolActivityIcon | undefined,
): boolean {
return toolIcon === undefined && iconName !== "computer";
}
function LiveActivityRow({
label,
iconName,
toolIcon,The sidebar follows the same intent at a different scale. It derives a visible order—pinned, active, a route-preserved snoozed row, and the visible settled tail— then stores ordering and lookup data in refs for callbacks. The comment is explicit that passing fresh collection identities through row props would defeat memoization during streaming. That is a narrowly scoped update-locality decision; it is not a claim that every update repaints only one DOM node.
onError: (error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to copy thread ID",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
},
});
const newThreadContext = useHandleNewThread();
const openAddProjectCommandPalette = useCallback(
() => openCommandPalette({ open: "add-project" }),
[],
);
const { environments } = useEnvironments();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
});
const routeDraftThread = useComposerDraftStore((store) =>
routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
);
const routeThreadRef = useMemo(
() => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
[routeDraftThread, routeTarget],
);
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
const routeTargetRef = useRef(routeTarget);
routeTargetRef.current = routeTarget;
// Post-settle navigation validates against the CURRENT route, not the one
// captured when the settle started: if the user navigated elsewhere while
// the command was in flight, completing it must not yank them away.
const routeThreadKeyRef = useRef(routeThreadKey);
routeThreadKeyRef.current = routeThreadKey;
const environmentLabelById = useMemo(
() =>
new Map(
environments.map((environment) => [environment.environmentId, environment.label] as const),Trace export is observability, not an application event log
Client tracing builds an OTLP delegate whose URL is resolved from the primary
environment and labels the resource as t3-web with browser or electron mode.
It replaces and closes an earlier tracing runtime when configuration changes, and
logs a sanitized configuration failure. This is a best-effort observability
pipeline: successful configuration makes a delegate available locally; it does not
prove remote collector durability or replay a product command.
const DEFAULT_EXPORT_INTERVAL_MS = 1_000;
const CLIENT_TRACING_RESOURCE = {
serviceName: "t3-web",
attributes: {
"service.runtime": "t3-web",
"service.mode": isElectron ? "electron" : "browser",
"service.version": APP_VERSION,
},
} as const;
const delegateRuntimeLayer = Layer.mergeAll(
primaryEnvironmentHttpLayer,
OtlpExporter.layerFlusher,
OtlpSerialization.layerJson,
Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
);
let activeDelegate: Tracer.Tracer | null = null;
let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null;
let activeScope: Scope.Closeable | null = null;
let activeConfigKey: string | null = null;
let configurationGeneration = 0;
let pendingConfiguration = Promise.resolve();
export interface ClientTracingConfig {
readonly exportIntervalMs?: number;
}
export function configureClientTracing(config: ClientTracingConfig = {}): Promise<void> {
if (config.exportIntervalMs === undefined && activeConfigKey !== null) {
return pendingConfiguration;
}
pendingConfiguration = pendingConfiguration.finally(() => applyClientTracingConfig(config));
return pendingConfiguration;
}
async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> {
const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces");
const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS);
const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;
if (activeConfigKey === nextConfigKey && activeDelegate !== null) {
return;
}
activeConfigKey = nextConfigKey;
const generation = ++configurationGeneration;
const previousRuntime = activeRuntime;
const previousScope = activeScope;
activeDelegate = null;
activeRuntime = null;
activeScope = null;
await disposeTracerRuntime(previousRuntime, previousScope);
const runtime = ManagedRuntime.make(delegateRuntimeLayer);
const scope = runtime.runSync(Scope.make());
const delegateResult = await settleAsyncResult(() =>
runtime.runPromiseExit(
Scope.provide(scope)(
OtlpTracer.make({
url: otlpTracesUrl,
exportInterval: `${exportIntervalMs} millis`,
resource: CLIENT_TRACING_RESOURCE,
}),
),
),
);
if (delegateResult._tag === "Failure") {
await disposeTracerRuntime(runtime, scope);
if (generation === configurationGeneration) {
const error = squashAtomCommandFailure(delegateResult);
const tracesUrl = new URL(otlpTracesUrl);
console.warn("Failed to configure client tracing exporter", {
scheme: tracesUrl.protocol.replace(/:$/, ""),
host: tracesUrl.hostname,
port: tracesUrl.port || undefined,A browser terminal renders a remote screen
The terminal drawer creates a Ghostty surface in the browser, but its session is addressed by environment, thread, terminal id, cwd, optional worktree, and runtime environment. User input becomes a typed write command; resize becomes a typed resize command; the renderer hydrates the terminal surface from the attached session’s buffered text and status. The client owns the screen component and local focus/selection behavior. The environment server owns the PTY, as Chapter 43 establishes.