Web & desktop

The surfaces operators actually touch

The web client is the active trading surface; the desktop app wraps the same bundle and adds process management. Every trading read they render comes from a server-built read model. Every mutation is either an RPC whose refusal lands on screen, or an event-sourced dispatch whose outcome lands in the feed.

exchange Hyperliquid testnet reconciler reconcile pass projection projection_trading_missions doorbell subscribeTradingAccount atom tradingMissionSnapshot panel TradeHomePanel fills, positions trading events invalidation one refetch per change atoms invalidate render
the invalidation loop: every trading event and every reconcile pass rings the doorbell once, the atom refetches, the panel re-renders. No timer sits inside this loop.

What the operator actually touches

The active client is apps/web, a React 19.2.6 single-page app built with Vite (apps/web/package.json:45). Two routes carry trading: /trade mounts the trade home (apps/web/src/routes/trade.tsx:3-7) and /settings/trading mounts the mission workspace (apps/web/src/routes/settings.trading.tsx:3-7). The desktop app is an Electron wrapper around this same bundle; the only runtime difference the web code sees is a telemetry label, client: "desktop" versus client: "web", keyed off whether the preload bridge exists (apps/web/src/lib/runtime.ts:24).

The discipline that shapes everything else: the client holds no trading truth of its own. Panels render server reads, and mutations either return their refusal to the screen or dispatch an event and wait for the projection to come back. The Event spine page covers the server half of that contract; this page covers the surfaces.

The web and desktop clients share their connection and state-management logic: the connection supervisor, the RPC session, and the atom factories live in packages/client-runtime and are consumed by web and desktop alike. The same code that reconnects a browser tab after a laptop wake reconnects the desktop app after its backend restarts.

Three columns and a follow rule

The trade home is described in one sentence in its own module header: watchlist on the left, the selected asset's chart in the center with positions beneath it, the alert feed on the right (apps/web/src/components/trading/TradeHomePanel.tsx:1-16). The layout is a single grid whose tracks are written down, not improvised: lg:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)_minmax(16rem,20rem)] (TradeHomePanel.tsx:279).

The grid, as built

watchlist
WatchlistPanel
chart
MissionPriceChart · MarketChartPanel
ticket
OrderTicket
positions
AccountPositionsPanel
alerts
AlertFeedPanel
ideas
IdeasPanel
tracks: minmax(15rem,18rem) · minmax(0,1fr) · minmax(16rem,20rem), every child min-w-0 so a wide row widens its own panel, not the viewport (TradeHomePanel.tsx:276-279)

The chart follows attention

The center column is not a fixed market. The selected asset is the user's explicit pick, else the first open position, else nothing: pickedAsset ?? positions[0]?.market.asset ?? null (TradeHomePanel.tsx:255-258).

With nothing watched or held, the chart area renders one sentence rather than an instruction manual: "The chart follows what you watch or hold. Ask the agent about any market and its chart is in that conversation." (TradeHomePanel.tsx:286-296)

An asset with a live mission draws that mission's chart with its overlays, entry price and liquidation included (TradeHomePanel.tsx:133-156); an asset without one gets the standalone market chart, entitled by the follow set (TradeHomePanel.tsx:169-177).

Two deliberate layout choices

The ticket stays mounted even when a mission owns the market. The server's market_owned_by_mission refusal in the preview is the honest explanation of why the ticket will not go through, so hiding the ticket would hide the reason (TradeHomePanel.tsx:304-308).

The right column stacks the alert feed and the ideas panel rather than tabbing them, because they answer different questions and a tab would hide one behind the other on a page whose whole point is one screen (TradeHomePanel.tsx:319-322).

What rides beside the grid

Above the columns sit three quiet lines that render nothing when all is well: the archiver-health line, the research-mode line, and the errors from the account and mission reads (TradeHomePanel.tsx:261-275). The sections below describe these explicit degraded states.

Clients render server read models

Every trading read the UI renders is a projector-built snapshot consumed through generated atom families. The mission state hook states the rule in its own doc comment:

"Everything here comes from projection_trading_missions, which the trading projector rebuilds from the event stream. There is no client-side mission state to go stale, and nothing is synthesized when the projection is empty." (apps/web/src/lib/tradingMissionsState.ts:41-47)

The families are declared once in packages/client-runtime/src/state/orchestration.ts: tradingMissionSnapshot (:60-63), tradingMarketChart (:64-67), tradingAccountView (:86-89), and the doorbell subscription tradingAccountInvalidations (:93-96). Panels consume them with useAtomValue and render projector output.

Push with a poll backstop. The account view and the mission snapshot used to be polled. Now the server publishes an invalidation on every trading event and every reconcile pass, and the subscription refetches on each one: that is what replaced the old 3 second poll (apps/web/src/lib/tradingMissionsState.ts:25-31). A 30 second interval survives only as a backstop for the cases push cannot cover: an older server without the subscription RPC, or a subscription that failed without a session change to restart it (tradingMissionsState.ts:32,56-71).

The account view itself declares "no poll interval here" (packages/client-runtime/src/state/orchestration.ts:83-89), and the trade home's three lists, watchlist, watches, and alerts, all ride the same doorbell (orchestration.ts:97-110).

30s
fallback poll behind the doorbell
tradingMissionsState.ts:32
60s
universe list cache; delisting is not a per-keystroke event
orchestration.ts:75-82
300s
workflow-script cache; scripts are immutable per run
orchestration.ts:39-45
15s
WebSocket open timeout on the RPC session
packages/client-runtime/src/rpc/session.ts:23

Two command classes, split by what a refusal means

RPC command
Mutations whose refusal the operator must see: arming a watch ("the user needs the refusal reason on screen, not an acknowledgement", orchestration.ts:124-130), dragging a stop level (:155-163), previewing an order (:176-186). The server answers synchronously and the sentence lands beside the control.
event-sourced dispatch
Mission creation, order placement, and deterministic controls use dispatched commands. Their outcomes arrive through the projection and feed. A control goes directly to the server without a bound agent-runtime turn, called a harness turn in this subsystem (orchestration.ts:144-154); placing an order is "answered through the feed" (:206-209).
fetch on click
Validation reports are commands rather than query atoms on purpose: a feed of fifty alerts should not read fifty reports to render the ones nobody opened (orchestration.ts:117-123). Research scenes are never held either, because the interesting moment is exactly when a scene appears (:68-74).

Preview on screen, outcome in the feed

The manual order ticket relays server truth verbatim: as the trader types, the ticket asks previewTradingOrder to price and pre-check the entry, and shows any refusal in the server's own sentence (apps/web/src/components/trading/OrderTicket.tsx:4-8). The stop field is mandatory; the ticket will not preview, let alone place, without one (OrderTicket.tsx:9-10).

Placing is a different shape of action. The dispatch is only the acknowledgement, and the outcome, filled or resting or refused, lands in the alert feed and the positions panel over the account doorbell (OrderTicket.tsx:12-15). The ticket never pretends an accepted dispatch was a fill.

Previews are debounced at 400 milliseconds after the last keystroke (OrderTicket.tsx:32). A fast typist can still outrun the network, so every response carries a sequence stamp from a monotonic ref, and a response whose stamp is no longer the latest is dropped on arrival: "Every response is matched against the latest request so a slow older preview can never overwrite a newer one" (OrderTicket.tsx:120,125-126,133,145). The stale preview is not corrected later; it is never applied.

Reconnection belongs to one place

The RPC client does not reconnect itself. The socket protocol is built with retryTransientErrors: false and a retry policy of zero recursions (packages/client-runtime/src/rpc/session.ts:99-102); its only clock is a 15 second open timeout (session.ts:23,96). Reconnection is the supervisor's job, and the supervisor is shared by web and desktop (packages/client-runtime/src/connection/supervisor.ts).

The supervisor's ladder is four rungs: 3, 4, 8, and 16 seconds, then 16 seconds forever, because the delay lookup clamps at the last rung (supervisor.ts:32-36,104-106). A connection that stays up for 30 seconds resets the ladder (BACKOFF_RESET_AFTER_MS = 30_000, supervisor.ts:36), and each attempt must establish within 15 seconds or it counts as a failure (supervisor.ts:33).

Two paths skip the ladder on purpose. When a foreground wake probe fails, the user is actively returning to the app on a dead transport, so the follow-up reconnect skips the first backoff rung; only that first attempt skips, and if it fails too, normal backoff resumes (supervisor.ts:236-239,713-727). And an application-active-reconnect wakeup replaces the lease outright, because mobile operating systems commonly suspend sockets without delivering a close event (supervisor.ts:416-420). A blocked supervisor that sees the application activate again also resets the ladder before retrying (supervisor.ts:689-695).

Degradation that stays quiet until it matters

The surfaces treat degraded states as a design problem with an explicit rule: render nothing while things work, and say the exact true sentence when they stop. Nothing blinks, pulses, or nags in the healthy case.

The staleness banner renders nothing, by design

The banner does not appear when data is merely late: it "renders nothing until the read has stopped landing altogether", because the band between late and stopped belongs to the live panel's own chip, and "a banner that appears and disappears on a cycle teaches the operator to ignore banners" (apps/web/src/components/trading/MissionStalenessBanner.tsx:16-27).

It is one component with two mounts, the workspace and the bound thread, "so the two cannot end up telling different stories about whether the position read is current" (MissionStalenessBanner.tsx:4-7). Its clock is read per render, not on a timer, so the banner can never disagree with the data it describes (MissionStalenessBanner.tsx:24-27,35-36). A failed read gets its own harder sentence: the feed error banner ends with "Nothing on this thread is refreshing." (MissionStalenessBanner.tsx:58-67)

Archiver health: one quiet amber line

Market-recording health is a single line in the staleness banner's visual register, absent while recording is demonstrably healthy (TradeHomePanel.tsx:62-76). Its sentences are exact per state: recording stopped means "New bars are not being collected; recorded history stays readable with its coverage shown"; the worst state refuses to guess: "Recording ownership cannot be verified, so charts and studies are refusing rather than guessing." (tradeHomePresentation.ts:56-73)

Research mode is a working state

With no signer armed, the home shows one line, not a modal or an onboarding flow: "Research mode: no trading key is configured. Observation, backtests and validations work; orders will be refused." (tradeHomePresentation.ts:87-92) The line exists so the user knows before an order is refused rather than after. The full boundary is on the Research mode page.

Conventions: one true line, next to the cause

Loading, empty, and error states in the trading panels are verbatim single-line strings placed beside the control that caused them: "Loading alerts…" (AlertFeedPanel.tsx:401), "Loading watchlist…" (WatchlistPanel.tsx:105-106). No skeletons, no spinners where a sentence will do.

Desktop notifications follow the same restraint. New alerts are selected by id rather than by count, so a feed that hit its window cap still reports the new arrivals; and the first read suppresses the entire backlog, because notifying history the user already lived through would ring fifty times (tradeHomePresentation.ts:105-124).

A destination that never moves on its own

The trade tab used to answer "which environment does the Trade tab trade on?" with "whatever project sorts first": the destination was derived from projects[0], which is not a choice anyone made and can silently change under the user (apps/web/src/components/trading/tradingEnvironmentSelection.ts:5-8). RC05 replaced that with an explicit, session-scoped destination. It is latched once, when the catalog first becomes ready: the valid primary, else the sole entry, else none, and several entries with no valid primary require an explicit choice before any trading query or control runs (tradingEnvironmentSelection.ts:10-14,70-78). After the latch, catalog, project, and primary changes never move the destination; an explicit choice is final for the session (tradingEnvironmentSelection.ts:54-59).

The surfaces render this as a five-state gate (tradingEnvironmentSelection.ts:128-148). A destination that vanishes keeps its identity and offers an explicit recovery action, Use <label> (<id>), in the selector (TradingEnvironmentSelector.tsx:64); the gate never silently falls back to another entry (tradingEnvironmentSelection.ts:143-147).

loading
The catalog is not ready, or the one-time latch has not run. The surfaces render "Loading trading environments…" and mount no environment-bound queries or controls; a partial catalog is never used to choose (TradeHomePanel.tsx:213-221).
no-environments
Nothing to route to. The line reads "Connect an environment to trade." The old destination is retained in session memory for recovery rather than cleared (TradeHomePanel.tsx:229-231; tradingEnvironmentSelection.ts:139-141).
choose
Several entries, no valid primary: "Choose an environment to trade." No query runs until the operator picks (TradeHomePanel.tsx:232-233).
unavailable
The chosen destination disappeared: "The selected trading environment is no longer available; choose an environment to continue." (TradeHomePanel.tsx:234) Recovery is explicit, never automatic.
selected
The environment subtree under the gate is keyed by the environment id, so switching resets local market and draft state and "late responses from the previous environment cannot land in the new one" (tradingEnvironmentSelection.ts:150-155; TradeHomePanel.tsx:199-204).

How environments are discovered, paired, and connected is the Relay & environments story; this gate is the last step, where a connection becomes a trading destination.

A press, its result, and the honest unknown

Before RC06, a risk-control press dispatched a command and the outcome of the exchange work lived only in server logs. The client half of the fix is a correlation rule: a dispatched command only proves the request was accepted; the mission's durable lastControlResult, written by the reactor after the exchange work finished, failed, or could not be confirmed, is what closes the loop (apps/web/src/components/trading/useMissionControls.ts:14-18).

resolveControlOutcome decides whether a result answers this press: it must be the same control, and it must have happened at or after the press, with a 1 second allowance for clock skew between the reactor's timestamp and the browser's (useMissionControls.ts:89-124, the comparison at :108-112). An older result belongs to an earlier press; a newer result for a different control belongs to another surface's press; neither may clear this one's pending state.

A press whose result never lands stops waiting after 30 seconds and reads as interrupted (useMissionControls.ts:69,120-122). The strip renders it plainly: the control's name, then "no final result" with the outcome unknown, in the amber register reserved for unknowns (MissionStripBar.tsx:126-141). Unknown is a first-class UI state, not an error to hide. The Risk control page covers the server half; the Failure stories page covers why this became a rule.

The desktop wrapper around the same bundle

The desktop app composes Electron services for the application lifecycle, dialogs, menus, power monitoring, protocol handling, secure storage, shell access, themes, and updates (apps/desktop/src/main.ts:1-80). Everything it adds is process and platform management around the web bundle the browser also runs.

One instance, revealed

The Clerk SDK bridge holds Electron's single-instance lock, acquired at bridge creation, so OAuth deep-link callbacks are forwarded to the running app. A secondary instance quits before whenReady can fire, and the running app's second-instance handler reveals the existing main window (apps/desktop/src/app/DesktopClerk.ts:128-147). Launching twice never yields two trading surfaces.

A pool of local backends

Desktop manages local server processes as a pool: a Windows primary brought up at startup, plus an optional WSL backend per distro with ids wsl:default or wsl:<distro>. Each registered instance gets its own child scope so it can be stopped cleanly, and the primary's id refuses unregister (apps/desktop/src/backend/DesktopBackendPool.ts:1-31). The renderer reconciles the extra entries through the same saved-environment path remote environments use, so the WSL backend appears in the sidebar without per-surface changes (DesktopBackendPool.ts:33-55).

The bridge that labels the bundle

The preload script exposes exactly one seam: contextBridge.exposeInMainWorld("desktopBridge", ...) (apps/desktop/src/preload.ts:30). The web runtime checks window.desktopBridge and labels its telemetry client: "desktop" when present, client: "web" otherwise (apps/web/src/lib/runtime.ts:24). One bundle, two skins, no fork in the trading code.

SSH without a password box in the wrong process

For remote environments over SSH, the desktop generates askpass scripts into a t3trade-ssh-askpass directory and points SSH_ASKPASS at them (packages/ssh/src/auth.ts:63,75); the password prompts themselves are rendered by the desktop layer, so the web bundle never handles the secret.

Controls work without the agent provider. The mission and risk controls are ordinary environment commands dispatched straight to the server: "no harness turn stands between the press and the action" (packages/client-runtime/src/state/orchestration.ts:144-154). Pause, cancel, reduce, close, and revoke are user controls, and the user does not need a running conversation to use them. The invariants behind this are on the Safety invariants page.

Go deeper

Where this page's claims live, grouped by the boundary they sit on. The Missions watches wakes page covers what the panels are showing; the Event spine page covers where the projections come from.

Reads and state

  • apps/web/src/lib/tradingMissionsState.ts:22-71 doorbell plus 30 second backstop
  • packages/client-runtime/src/state/orchestration.ts:60-123 every trading atom family
  • apps/web/src/components/trading/TradeHomePanel.tsx:1-16,255-330 layout and follow rule
  • apps/web/src/components/trading/tradeHomePresentation.ts:25-124 the quiet lines and their sentences

Connection, destination, outcome

  • packages/client-runtime/src/connection/supervisor.ts:32-36,713-727 the ladder and its skips
  • packages/client-runtime/src/rpc/session.ts:23,99-102 the session that will not self-heal
  • apps/web/src/components/trading/tradingEnvironmentSelection.ts:1-189 the latched destination
  • apps/web/src/components/trading/useMissionControls.ts:14-124 press-to-result correlation
  • apps/desktop/src/app/DesktopClerk.ts:128-147 the single-instance lock