Part VI · Client architectures: shared semantics, platform edgesWeb runtime
Chapter 30source checked

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.

apps/web/src/main.tsx:1–61 ↗verbatim · tsx · 469fcbad
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>,
);
Read this as: The entry selects hash history only under the Electron bridge, creates one router, and conditionally wraps the same AppRoot in the appropriate authentication provider.

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.

apps/web/src/AppRoot.tsx:1–23 ↗verbatim · tsx · 73f37a46
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>
  );
}
Read this as: AppRoot supplies the atom registry to the router and persistent renderer hosts; the Electron browser host is intentionally outside route transitions.
apps/web/src/router.ts:1–20 ↗verbatim · typescript · 0327fd93
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;
  }
}
Read this as: The router is constructed from the generated route tree and receives the history chosen by main.tsx.

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.

apps/web/src/connection/catalog.ts:1–5 ↗verbatim · typescript · 94a883ff
import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections";
 
import { connectionAtomRuntime } from "./runtime";
 
export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime);
Read this as: The web catalog is constructed from the shared connection atom runtime.
apps/web/src/state/session.ts:7–41 ↗verbatim · typescript · cb1d8326
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,
  };
Read this as: Prepared connections and authentication session state are addressed by EnvironmentId; a null selection uses a deliberately empty atom.

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.

Figure 30.1 · One renderer, explicit platform and hot-path seamssolid arrows carry ordinary data; dashed arrows are optional runtime edges
Web runtime architecture and hot pathDiagram loading

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.

Web runtime architecture and hot path
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.

Figure 30.1. The entry creates one router from a history chosen at the runtime edge. AppRoot supplies a shared atom registry to routed UI and persistent hosts. A chat route selects an environment-scoped thread connection; snapshot plus stream state feeds the virtualized timeline and memoized sidebar rows. Terminal bytes remain server-owned and arrive through the terminal session. Client tracing exports to the selected primary environment's observability endpoint. Electron-only browser hosting is outside the router. This is an architecture diagram, not a latency, throughput, or memory benchmark.

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.

apps/web/src/components/chat/MessagesTimeline.tsx:570–648 ↗verbatim · tsx · ad64d1e1
  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;
}
Read this as: The timeline passes stable keys, estimated size, scroll anchoring, and visible-content maintenance policy to LegendList.
apps/web/src/components/chat/MessagesTimeline.tsx:2095–2113 ↗verbatim · tsx · 74026e98
// 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]);
}
 
// ---------------------------------------------------------------------------
Read this as: The row helper remembers prior rows and returns prior object references when the relevant row content did not change.

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.

apps/web/src/components/Sidebar.tsx:2200–2252 ↗verbatim · tsx · 0ca7fefc
    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);
Read this as: The sidebar preserves a routed snoozed row and uses refs for order/maps so streaming updates do not force fresh callback props into every memoized row.

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.

apps/web/src/observability/clientTracing.ts:15–96 ↗verbatim · typescript · ea79ca0a
 
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") {
Read this as: The client tracer labels browser/Electron mode, resolves an observability URL from the primary environment, and replaces prior tracing state on configuration changes.

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.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.