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 { ClerkProvider } from "@clerk/react";
import { passkeys } from "@clerk/electron/passkeys";
import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react";
import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
import "./index.css";
import { isElectron } from "./env";
import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
import { hasCloudPublicConfig } from "./cloud/publicConfig";
import { getRouter } from "./router";
import {
syncDocumentElectronPlatformClasses,
syncDocumentWindowControlsOverlayClass,
} from "./lib/windowControlsOverlay";
import { AppRoot } from "./AppRoot";
import { clerkAppearance } from "./components/clerk/clerkAppearance";
// 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;
// First Clerk UI build containing https://github.com/clerk/javascript/pull/9500.
const electronClerkUI = {
__internal_clerkUIVersion: "1.30.5-canary.v20260819050620",
};
const app = <AppRoot router={router} />;
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
{clerkPublishableKey && hasCloudPublicConfig() ? (
isElectron ? (
<ElectronClerkProvider
{...electronClerkUI}
appearance={clerkAppearance}
publishableKey={clerkPublishableKey}
passkeys={passkeys}
>
<ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
</ElectronClerkProvider>
) : (
<ClerkProvider appearance={clerkAppearance} publishableKey={clerkPublishableKey}>
<ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
</ClerkProvider>
)
) : (
app
)}
</React.StrictMode>,
);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: {},
});
}
export type AppRouter = ReturnType<typeof getRouter>;
declare module "@tanstack/react-router" {
interface Register {
router: AppRouter;
}
}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.
if (rows.length === 0 && !isWorking) {
if (hideEmptyPlaceholder) {
return null;
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-placeholder text-sm">Send a message to start the conversation.</p>
</div>
);
}
return (
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<div ref={setTimelineViewportElement} className="relative h-full min-h-0">
<LegendList<MessagesTimelineRow>
ref={listRef}
data={rows}
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={contentInsetEndAdjustment}
maintainScrollAtEnd={
anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
? false
: TIMELINE_MAINTAIN_SCROLL_AT_END
}
maintainVisibleContentPosition={maintainVisibleContentPosition}
onScroll={handleScroll}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "topbar-scroll-fade",
)}
ListHeaderComponent={
loadEarlier !== null ? (
<TimelineLoadEarlierHeader
loading={loadEarlier.loading}
onLoadEarlier={loadEarlier.onLoadEarlier}
fade={topFadeEnabled}
/>
) : topFadeEnabled ? (
TIMELINE_LIST_FADE_HEADER
) : (
TIMELINE_LIST_HEADER
)
}
ListFooterComponent={TIMELINE_LIST_FOOTER}
/>
<TimelineMinimap
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
stripMap={minimapStripMap}
onSelect={(item) => {
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
viewOffset: 24,
});
}}
/>
</div>
</TimelineRowActivityCtx>
</TimelineRowCtx>
);
});
function keyExtractor(item: MessagesTimelineRow) {
return item.id;
}
function getItemType(item: MessagesTimelineRow) {
return item.kind === "message" ? `message:${item.message.role}` : item.kind;
}// so LegendList (and React) can skip re-rendering unchanged items.
// ---------------------------------------------------------------------------
/** Returns a structurally-shared copy of `rows`: for each row whose content
* hasn't changed since last call, the previous object reference is reused. */
function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
const prevState = useRef<StableMessagesTimelineRowsState>({
byId: new Map<string, MessagesTimelineRow>(),
result: [],
});
return useMemo(() => {
const nextState = computeStableMessagesTimelineRows(rows, prevState.current);
prevState.current = nextState;
return nextState.result;
}, [rows]);
}
// ---------------------------------------------------------------------------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.
SNOOZED_SHELF_EXPANDED_KEY,
false,
Schema.Boolean,
);
const toggleSnoozedShelf = useCallback(
() => setSnoozedShelfExpanded((value) => !value),
[setSnoozedShelfExpanded],
);
const visibleSnoozedThreads = useMemo(() => {
if (snoozedShelfExpanded) return snoozedThreads;
// The open thread must never vanish behind the collapsed shelf: a
// snoozed thread reached by route (deep link, open before snoozing
// elsewhere) keeps its row — with highlight and wake affordance — same
// exception the settled tail's "Show more" makes.
if (routeThreadKey === null) return [];
const routeThread = snoozedThreads.find(
(thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
);
return routeThread === undefined ? [] : [routeThread];
}, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
const orderedThreads = useMemo(
() => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
[pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],
);
const orderedThreadKeys = useMemo(
() =>
orderedThreads.map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
),
[orderedThreads],
);
// Rows call back into the click handler without carrying the ordered list as
// a prop — a fresh array identity per shell update would defeat every row's
// memoization. The ref keeps shift-range-select working against the list as
// rendered at click time.
const orderedThreadKeysRef = useRef(orderedThreadKeys);
orderedThreadKeysRef.current = orderedThreadKeys;
const threadByKey = useMemo(
() =>
new Map(
orderedThreads.map(
(thread) =>
[scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
),
),
[orderedThreads],
);
// Handlers read these through refs: depending on per-update Map/Set
// identities would give every row a fresh callback prop on each shell
// event and defeat row memoization during streaming.
const threadByKeyRef = useRef(threadByKey);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 const ClientTracingLive = Layer.succeed(
Tracer.Tracer,
Tracer.make({
span(options) {
return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options);
},
}),
);
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") {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 28 establishes.