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.
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
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:
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).
tradingMissionsState.ts:32
orchestration.ts:75-82
orchestration.ts:39-45
packages/client-runtime/src/rpc/session.ts:23
Two command classes, split by what a refusal means
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.
orchestration.ts:144-154); placing an
order is "answered through the feed" (:206-209).
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).
TradeHomePanel.tsx:213-221).
TradeHomePanel.tsx:229-231; tradingEnvironmentSelection.ts:139-141).
TradeHomePanel.tsx:232-233).
TradeHomePanel.tsx:234) Recovery is explicit, never automatic.
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-71doorbell plus 30 second backstop -
packages/client-runtime/src/state/orchestration.ts:60-123every trading atom family -
apps/web/src/components/trading/TradeHomePanel.tsx:1-16,255-330layout and follow rule -
apps/web/src/components/trading/tradeHomePresentation.ts:25-124the quiet lines and their sentences
Connection, destination, outcome
-
packages/client-runtime/src/connection/supervisor.ts:32-36,713-727the ladder and its skips -
packages/client-runtime/src/rpc/session.ts:23,99-102the session that will not self-heal -
apps/web/src/components/trading/tradingEnvironmentSelection.ts:1-189the latched destination -
apps/web/src/components/trading/useMissionControls.ts:14-124press-to-result correlation -
apps/desktop/src/app/DesktopClerk.ts:128-147the single-instance lock