Part VII · Client architectures: shared semantics, platform edgesWeb runtime
Chapter 45source 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 · 7ce8df0e
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(),
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 · cfb3a145
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 {
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 45.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 45.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 · 0878401a
    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]);
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 · 5b040e86
    </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,
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 · d1766b54
    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),
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 · 6177c54d
 
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,
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 43 establishes.

T3
Source-locked editionRead against 859304b78 · 10 Sept 2026
Book search

Find a concept, module, or source path

Type two or more characters.