The Visualizer (Workspace tab, { kind: "visualizer" }) is a live, interactive node-graph of agent orchestration - agents, subagents, tool calls, message bubbles, timeline, file-attention heatmap - rendered by the vendored render layer of Agent Flow (Apache 2.0, by Simon Patole) and fed entirely by Otto's own provider-neutral event stream. It ships for every provider on day one because the adapter consumes the already-normalized protocol stream, never the vendor's own Claude/Codex ingestion.
Opening: the Visualizer is a companion view - the user watches it alongside the chat or orchestration that's beginning - so openVisualizerTab (packages/app/src/visualizer/open-visualizer-tab.ts, the single entry point for the header button and the Runs "Visualize" action) opens it in a split to the right of the focused pane, never covering the pane the user is in. It falls back to opening/focusing in place when splits are unsupported (native), the tab is already split out into another pane, the focused pane has nothing else to watch alongside, or the split tree is at max depth.
Opening from an app-wide screen: Workflows (/runs) is not inside any workspace, so its "Visualize" action passes navigate: true. openVisualizerTab then routes to the run's workspace through navigateToWorkspace with the Visualizer tab as the explicit target (see expo-router.md), which POP_TOs an already-mounted host route rather than appending hidden deck entries, and keeps the named tab authoritative so no attention agent opens over it. The tab already exists by then, so the navigation just focuses it where it was placed. Without the flag the tab would open into a background workspace layout and the press would look like it did nothing.
Trademark: the vendor project's name and logos are trademarked (vendor/agent-flow/TRADEMARK.md). Never use "Agent Flow" as a UI label - the feature name is "Visualizer", locked. Never ship the vendor's own icons. Attribution lives in Settings → About ("Visualizer is derived from Agent Flow (Apache 2.0) by Simon Patole", see packages/app/src/utils/visualizer-attribution.ts).
A git subtree (squashed) of https://github.com/patoles/agent-flow (remote agentflow), imported in stock subtree format so standard tooling works. Only web/ is compiled; extension/, app/, scripts/ are inert reference (extension/src/protocol.ts carries the canonical bridge protocol upstream itself compiled against).
Rules:
- No Otto code inside
vendor/. All integration lives inpackages/visualizer/(build) andpackages/app/src/visualizer/(embed + adapter). - In-vendor patches are a last resort, logged in
vendor/agent-flow/OTTO-PATCHES.md(file, what changed, why) - this file doubles as the Apache 2.0 "state changes" notice. Prefer upstream PRs over carrying patches; the carried set (runtime→logo mapping, host-seedable panel config, always-visible session tabs,config.renderlayer toggles,otto-codefont markers, host-seedable color palette) is enumerated in that file with full rationale. - Never format or lint the vendor tree -
vendor/**is ignore-listed in.oxfmtrc.json/.oxlintrc.json. The emitted bundle uses the.gen.tssuffix, which oxfmt already ignores. - React pinning:
packages/visualizerpins react/react-dom to the app's exact version so one hoisted copy serves both.
git fetch agentflow main --no-tags
LEFTHOOK=0 git subtree pull --prefix vendor/agent-flow https://github.com/patoles/agent-flow.git main --squashLEFTHOOK=0 matters - pre-commit format-check/lint would fail on vendor code. If the pull conflicts with local patches, resolve inside vendor/, keeping OTTO-PATCHES.md accurate.
Then: diff upstream's build inputs (web/package.json, web/vite.config.shared.ts, web/webview-entry.tsx, web/lib/bridge-types.ts) against what packages/visualizer/ mirrors; re-apply any OTTO-PATCHES.md patch upstream's diff clobbered; rebuild (npm run build:visualizer, which regenerates the committed packages/app/src/visualizer/visualizer-bundle.gen.ts); verify the demo scenario in a visible pane, then a live agent session; npm run typecheck && npm run lint.
Escape hatch: if upstream drift makes pulls uneconomical, stop pulling - the vendor tree freezes and Otto owns it. Nothing else changes.
packages/visualizer/ is a build-time-only workspace package:
src/otto-entry.tsxreplaces upstream'swebview-entry.tsx: binds the vendor bridge to whichever host transport is present (Electronwindow.__ottoVisualizerPost→ RNwindow.ReactNativeWebView.postMessage→ iframewindow.parent.postMessage), resolved lazily per call so late-injected transports win.vite.config.tsmirrors upstream'screateBuildConfig(IIFE, singleindex.js/index.css; demo/SSE/telemetry compiled out).scripts/emit-bundle.mjswraps the build into one self-contained HTML shell (artifact-style CSP,connect-src 'none') and emitspackages/app/src/visualizer/visualizer-bundle.gen.ts(committed, ~975 KB string - mostly the two embedded font faces, see Fonts & type scale).npm run build:visualizer/build:visualizer:demofrom the root.
Consumers import() the bundle lazily (load-visualizer-html.ts) - never at module top level - so it's only fetched once a Visualizer tab actually mounts.
packages/app/src/visualizer/visualizer-view.{web,electron,tsx} - Metro-split per CLAUDE.md's platform-gating convention:
- Web: sandboxed
<iframe srcDoc={VISUALIZER_HTML} sandbox="allow-scripts">, noallow-same-origin. Needs zero page cooperation - the entry falls back towindow.parent.postMessage. - Electron:
<webview>on its own non-persistent partition (otto-visualizer). Not an iframe - the app-shell CSP (script-src 'self') is inherited by same-document srcDoc iframes and blocks the inline bundle; a<webview>guest on its own session escapes it. Page → host has no other IPC, so the guest logs a prefixed JSON string (__OTTO_VIS__...) and the host parsesconsole-message. The partition being fresh per app run is also why the vendor page's sound effects default muted (web/hooks/use-audio-effects.tsonly unmutes whenlocalStorage[agent-viz-sound] === "on", and this partition's storage starts empty every run). - Native:
react-native-webview,source={{ html: VISUALIZER_HTML }}.
All three keep the WebView/webview mounted for the tab's lifetime (no unmount on blur) so accumulated page state survives tab switches.
Resource sleep (off-screen tabs cost nothing): a Visualizer that isn't on screen renders zero frames. This is enforced explicitly rather than left to Chromium's frame throttling: visualizer-surface.tsx sends config.paused whenever the pane is not visible, its retained panel is inactive, or the app is not visible, and the page's render gate (vendor/agent-flow/web/lib/render-gate.ts, an Otto patch) stops every requestAnimationFrame loop - canvas draw, simulation, FPS meter - while paused or while its own document is hidden, restarting them on resume. So the whole canvas pipeline (bloom, particles, the graph) halts with no CPU/GPU cost. Two layers put an off-screen Visualizer behind display:none: a non-frontmost tab in its pane (RetainedPanel active={isVisible} in split-container.tsx), and a background workspace (WorkspaceDeck mounts up to mountedWorkspaceLimit workspaces, each a RetainedPanel active={isActive}; beyond the cap, or after WORKSPACE_DECK_INACTIVE_TTL_MS unshown, the workspace - and its WebView - is fully unmounted). The Otto-side adapter is independently gated off whenever the pane isn't on screen (usePaneFocus().isVisible, i.e. workspace focused and frontmost tab), so it does no backfill/streaming work either. The one deliberate exception is a visible companion split - a Visualizer sharing the focused workspace with the pane you're typing in is on screen and stays live (that's the whole point of the companion view). Bringing an asleep Visualizer back into view flips isVisible true, whose reset+replay rehydrates the graph from the session buffers (see "Backfill + liveness").
web/lib/vscode-bridge.ts is the render layer's only transport, and it's pluggable - this is the seam Otto's embed drives instead of the vendor's VS Code extension.
- Host → page:
__vscode-bridge-init,agent-event/agent-event-batch({time, type, payload, sessionId?}),config({mode, autoPlay, showMockData, disable1MContext, panels, render, soundVolume, hudHidden}),connection-status,reset,session-list/session-started/session-ended/session-updated,select-session/close-session({sessionId}, Otto patch - drive the switcher from the toolbar). Otto also emitsfile_collisionevents after comparing canonical file paths across sessions. - Page → host:
ready,open-file({filePath, line?}),sound-muted({muted}),panel-toggle({panel}, Otto patch - a page keyboard shortcut deferring a panel toggle to the host, which owns panel visibility),session-state({sessions, selectedId, activityIds}, Otto patch - mirror the live session list to the toolbar).
The chat switcher and every canvas control live in a native Otto toolbar at the top of the visualizer tab (packages/app/src/panels/visualizer-toolbar.tsx), not in the in-webview HUD. The left side carries the chats SelectField dropdown, the Audio-mute toggle, then a separated cluster of the controls that used to live in the in-canvas right-click menu - grouped with ToolbarSeparators exactly as the menu was: Zoom to Fit · Toggle Stats | Restart. The right side carries the Timeline · Files/Cost/Failures · HUD-eye toggles (mirroring file-view-mode-bar.tsx). The toolbar is always visible - the HUD-eye in it drives visualizerHudHidden → config.hudHidden, which hides only the in-webview HUD.
The controls split by how they carry state:
- Config-driven followers - the Timeline / Files / Cost / Failures / Toggle Stats / Audio / HUD toggles each flip a device-local setting (
visualizerPanelTimeline/…FileAttention/…CostOverlay/…Failures/…Stats/…SoundMuted/…HudHidden) that theconfigeffect already pushes, so the page stays a single config-driven follower and the toolbar's selected state reflects the setting. Files, Cost, and Failures share one panel position and are mutually exclusive.visualizerPanelStatsridesconfig.panels.stats(an OTTO patch - the vendor'sshowStatswas page-only state before). The page's own panel keyboard shortcuts (t/f/$/s) defer to the host the same way: with a bridge host attached they send a page→hostpanel-toggle({panel}) that flips the matching setting - flipping page-local state instead would desync the toolbar and get snapped back by the next config push; the local flip survives only as the standalone/demo fallback (OTTO patch).
Failures and file overlaps. Failed tool calls remain inspectable after their brief canvas cards fade. The Failures panel retains the latest 100 in the selected simulation, including expandable error text (up to 2,000 characters) and later identical attempts; result token counts are estimates, never dollar costs. Tool starts and ends carry Otto's stable callId, so concurrent calls to the same tool are matched correctly. File overlaps are derived before session filtering: Otto normalizes each file's physical path, compares reads and writes from distinct agents within 90 seconds, and ignores read/read pairs and different worktrees. Each involved chat receives the same collision id. The page shows a short dashed trail when both agents are visible and an expandable file-overlap strip for cross-session cases; the strip is hidden in compact PIP and with the HUD. The strip displays a workspace-relative path but opens the canonical physical path, which matters when the two chats have different workspace roots. Detection and retained notices are bounded in memory.
- The chats dropdown is driven by the
session-statemirror +select-sessioncommand: the page still owns the session state machine (auto-select, per-session buffers, switch coordination), so the host renders + remote-controls it rather than reimplementing it. - Zoom to Fit / Restart are stateless one-shot actions (nothing to persist), sent as a host→page
viewport-command({action: "zoom-to-fit" | "restart" | "cold-restart"}, an OTTO patch); the page runs the action (setZoomToFitTrigger/handleRestart/handleColdRestart). They render as momentaryToolbarButtons and are disabled when no chat is selected (the empty state), since there's nothing to fit or restart - except while the demo scenario runs, which is a live simulation with no session selected, so both re-enable and Restart replays the demo from the top. Stats stays usable throughout. - Demo starts / stops the built-in demo scenario - see The demo scenario for the whole contract. It is the one control that is conditionally rendered rather than disabled, and like the PIP control it never collapses (it is the only way back out).
The in-canvas right-click GlassContextMenu was removed once all four of its actions had a home in the toolbar (OTTO patch) - right-click is now a no-op that still swallows the browser's native menu. What stays in the HUD (under the HUD-eye): the top-right stats readout, the Timeline/Files/Cost panels + popups, the bottom play/scrubber control bar, and the FPS meter.
The vendored bundle ships a scripted mock run (web/lib/mock-scenario.ts) - a full multi-agent session with spawns, tool calls, subagent dispatches and a completion. It is what the visualizer shows when nothing real is attached, and it is the fastest way to see the feature move. The toolbar's Demo button plays it; visualizer-surface.tsx owns the transition.
Where it is offered. The demo takes over the whole canvas, so the button only exists where there is nothing of yours to trample: the page reports no chats and the workspace has no chat tab open. The tab half of that test is not redundant - the page's session-state mirror is legitimately empty for the first few hundred ms after the guest loads, before the adapter's first flush, and without it the button would flash into the toolbar on every tab open. It stays rendered for as long as the demo runs, or there would be no way out. PIP never gets it (PIP renders no controls at all).
How the transition works. Three things move together, and all three are required:
config: {showMockData: true | false}switches the page's event source betweenMOCK_SCENARIOand the host's live batches. (modeandautoPlayare in the config union for the upstream VS Code extension's benefit; the web page reads neither.)viewport-command: "cold-restart"(OTTO patch) rewinds the simulation clock to 0 and empties the canvas. Mandatory in both directions. Entering without it dumps the whole scenario in a single frame - the page auto-plays on mount, so the clock has been advancing since the tab opened and every event whosetimeis already behind it fires at once. Leaving without it strands the mock nodes on screen, because the plainrestartdeliberately keeps still-running agents.- The event adapter is gated off (
active: ready && isVisible && !demoActive) for the duration, so the live stream and its reset+replay can't fight the mock run. The follow-the-active-chat effect is gated the same way: aselect-sessionwould restore that chat's cached simulation state right over the demo.
Ordering matters on the way out. The demo effect is declared before useVisualizerEventAdapter so that React - which flushes every pending cleanup first, then runs effect bodies in declaration order - lands showMockData: false before the reactivating adapter posts its reset and replays the real chats. The other order makes the page swallow that replay: it ignores host events while mock data is on.
A guest reload mid-demo (a theme or Sharpness change) is handled: the handshake-reset effect clears the "what the page was last told" ref alongside ready, so the demo is re-entered against the fresh page.
packages/visualizer also has a standalone demo shell - npm run build:visualizer:demo writes .demo/index.html (gitignored) with NEXT_PUBLIC_DEMO=1 baked in. That is for verifying the vendor bundle outside Otto; the production shell always ships DEMO=0 and relies on the config path above.
An optional render: Partial<{bloom, stars, backdrop, nodeShape, showFps, contextDisplay}> field toggles the canvas's decorative layers and picks the agent-node silhouette, applied live on every config message that carries it: bloom is the blurred additive glow pass (three full-canvas blur passes per frame - the single most expensive draw stage; disabling it also removes the "blurry echo" of bright elements), stars the 80 parallax depth particles, backdrop the void fill + ambient spotlight. Omitted layer keys keep the layer on. showFps renders a small HUD FPS meter (FpsMeter, fps-meter.tsx) pinned to the bottom-right corner - themed with the HUD holo palette, counting the page's own rAF ticks, mounted only while enabled, and independent of the HUD-visibility toggle (it's a perf diagnostic); off by default. nodeShape ('square' | 'hexagon' | 'octagon' | 'circle'; the device-local default is circle, and an omitted config leaf falls back to the historical hexagon) selects the shape every node body draws - the whole silhouette (depth shadow, glow fill + ambient ring, scanline clip, state ring, waiting ripples) follows it via drawNodeShape (canvas/draw-misc.ts), and the transient spawn/complete burst ring follows it too (drawEffects, canvas/draw-effects.ts) so the ring matches the node it emits from; the center brand glyph, the circular context ring, and the completion ring's soft radial glow are shape-agnostic. contextDisplay ('ring' | 'bar', device-local default ring) picks which single occupancy readout the main agent node draws: the page used to draw the circular ring and the segmented bar beneath it - the same number twice. In ring mode the bar and its segments are dropped and the x / y tokens label moves up into the bar's slot (drawContextComposition(..., 'label')); in bar mode the ring is dropped. Sub-agent nodes never had a ring and always keep the bar. Sourced from visualizerRender* / visualizerNodeShape / visualizerContextDisplay device-local settings in the dedicated Settings → Visualizer section (developer mode only), sent by visualizer-panel.tsx together with panels - on the page's ready handshake and re-sent live whenever a setting changes.
The same settings section owns Sharpness (visualizerRenderQuality: Fast 1x / Balanced 1.25x / Sharp 1.5x / Native): it substitutes the shell's __OTTO_DPR_CAP__ placeholder via applyVisualizerRenderScale (load-visualizer-html.ts). The page reads dpr once at boot, so a quality change rebuilds the html string, which remounts the guest - the panel resets its handshake state on the change so the fresh ready re-runs config + adapter replay.
An optional soundVolume: number (0..1) sets the vendor page's master audio volume for its procedural sound effects (agent spawn, tool start/end, completion chord, error tone). It's authoritative in the page: 0 mutes, > 0 is audible at that level and unmutes (driving the in-page mute toggle's icon so it stays truthful). Sent live on every config message that carries it, applied via AudioEngine.setVolume. Sourced from the visualizerSoundVolume device-local setting (stored as a 0-100 percent, ÷100 on the way out), surfaced as the Volume slider in the Settings → Visualizer "Sound" section. The effective value is gated by the visualizerSoundMuted setting (muted ? 0 : volume/100); the defaults are volume 50, unmuted - so first-time users hear the feature at 50%, and the in-page speaker button is the mute switch (persisted host-side, since the vendor page's own localStorage mute pref resets every run on Otto's fresh webview partition).
An optional panels: Partial<{timeline, fileAttention, costOverlay, stats}> field seeds which page panels/overlays start visible - applied on every config message that carries it, not just the first (stats = the per-node stats readout overlay, driven by the toolbar's Toggle Stats button). fileAttention/costOverlay are mutually exclusive in the vendor page itself (toggleExclusivePanel); a config setting both true is resolved by priority (files > cost). Sourced from device-local settings (packages/app/src/hooks/use-settings/storage.ts visualizerPanel* fields, Settings → Appearance → Visualizer, developer mode only) and sent once by visualizer-panel.tsx on the page's ready handshake.
The chat-transcript ("Chat") panel, the top-left message feed, and the per-node chat panel (click a node → that one agent's messages) were all removed from Otto's embed (2026-07-16 and 2026-07-17 vendor patches - OTTO-PATCHES.md): the visualizer is a companion to the real chat the user already has open, so reproducing message content in the graph was pure duplication. Clicking a node now shows only the node detail card (pinned middle-right, formerly middle-left), enriched with info the graph itself doesn't spell out: the agent's task, a cost estimate (from the honest lifetime total via agentCost), lifetime tokens (when they exceed context occupancy), and a labeled context-composition breakdown (one row per reported category, with token counts - the labels come from the data, since they are the provider's own category names; the node ring shows only proportions), alongside the existing context %, tool count, time alive, state, model, and current tool. The Files heatmap and the Cost overlay stay.
The floating on-canvas message bubbles are also gone - but the messages stay in the record. The graph used to draw a popup bubble for every assistant/user/thinking message; that was the last surface duplicating the real chat the user already has open, and it obscured the orchestration the graph exists to show. A vendor patch (OTTO-PATCHES.md "suppress the floating on-canvas message bubbles") drops only the popup: handleMessage no longer pushes onto agent.messageBubbles (so draw-bubbles/hit-detection/camera all short-circuit on the now-always-empty array), but still calls appendConversation and still transitions the node to thinking on message activity. So a message is unchanged as part of the record - it still feeds the execution timeline and the node's account of what it did ("it's what really happened") - and the host keeps emitting message events exactly as before (the adapter's streaming-message coalescing is untouched). The sole, deliberate exception is the fully hidden chat background: it derives the focused root AI's latest assistant reply from that same conversation record and displays it through this native canvas bubble system. Needs build:visualizer. This is the deliberate line: the chat stays in the timeline/history; only the visual popups are hidden outside that focused background state.
config.hudHidden (Otto patch)
An optional hudHidden: boolean collapses the HUD chrome - the top bar and the bottom control bar - leaving the canvas graph and its inline informational surfaces (agent/tool/discovery popups, the per-node chat panel). Authoritative when present, applied on every config message that carries it (same shape as config.panels). The whole control is the toolbar's HUD-eye: it flips the visualizerHudHidden device-local setting, which the panel re-seeds as config.hudHidden - so the toggle is shared by every Visualizer tab at once (all read the one setting) and survives restarts; there is no in-page button and no Settings row. (The in-page bottom-left button and its hud-hidden page→host report were removed with the native-toolbar move - the page is a pure config follower here.)
Hiding the HUD also hides the slide-in panels. The Timeline / Files / Cost / Stats overlays are driven by config.panels, independent of config.hudHidden in the vendor page - so visualizer-panel.tsx force-sends config.panels all-off while hudHidden is set (!hudHidden && settings.visualizerPanel*), giving a genuinely clean canvas view instead of leaving a stale panel open. This reads the stored visualizerPanel* settings but never writes them (same preserve-the-preference pattern as software-rendering forcing bloom off), so re-showing the HUD restores exactly the panels that were open before. To match, the toolbar's four panel toggles are disabled and shown unselected while the HUD is hidden (panelsDisabled = hudHidden in visualizer-toolbar.tsx), and the page's own t/f/$/s keyboard shortcuts (the panel-toggle page→host path) are ignored while hidden so they can't silently flip a stored setting behind the disabled buttons.
Agent['runtime'] is widened from upstream's 'claude' | 'codex' to also include 'copilot' | 'opencode' | 'pi' | 'openai-compat'. Only Claude and Codex have real brand marks (trademark rule); every other known runtime draws a generic hollow-diamond mark instead of silently falling back to the Claude spark. packages/app/src/visualizer/visualizer-event-adapter.ts resolveVisualizerRuntime maps Otto's provider ids (claude, codex*, copilot, opencode, pi, omp → 'openai-compat') to these literals; a user-defined custom openai-compatible provider (arbitrary id) still omits runtime and gets the Claude-spark default.
| Event | Payload (loosely typed, missing fields tolerated) |
|---|---|
agent_spawn |
{name, parent?, isMain?, task?, model?, runtime?, colorA?, colorB?} - name is the node id; re-spawn of an existing name reactivates it; colorA/colorB are the personality's identity colors (see Personality-colored nodes) |
agent_complete |
{name} - cascades completion to children + running tools |
agent_idle |
{name, resting?} - tool_calling/waiting_permission → thinking; resting: true (Otto patch) → the dim idle state (real turn end) |
model_detected |
{agent, model} - drives context-window size + cost rate |
tool_call_start |
{agent, tool, args, inputData?} - inputData.file_path feeds the file-attention heatmap (Read/Edit/Write) |
tool_call_end |
{agent, tool, result?, tokenCost?, isError?, errorMessage?} |
message |
{agent, content, role: 'user'|'assistant'|'thinking'} - first user message renames the main node |
context_update |
{agent, tokens, tokensMax?, breakdown?} |
subagent_dispatch / subagent_return |
{parent, child, task/summary} |
permission_requested |
{agent} |
Multi-session: the page keeps per-sessionId event buffers and renders session tabs; the host streams tagged events plus session lifecycle messages.
The page renders in Otto's fonts at Otto's chat text size, not the vendor's mono-everywhere Geist look. Ownership is split between the shell and one small vendor patch:
- Shell stylesheet (
emit-bundle.mjs, emitted after the bundle CSS so equal-specificity selectors win by order): mapsbody/.font-sans/.font-monoto--otto-ui-fontand.otto-codeto--otto-code-font, and scales the vendor's authored 9/10/11/12px DOM type ramp by--otto-font-scaleso its 10px content size lands exactly on Otto's chat prose size (theme.fontSize.sm). Upstream usesfont-monoas its general interface voice, which is why it maps to the interface font - only genuinely-code surfaces (tool diff/command blocks, file paths) keep a monospaced face, via theotto-codemarker class the vendor patch adds (seeOTTO-PATCHES.md). - Why class overrides, not CSS variables: the vendor's
globals.cssuses Tailwind v4@theme inline, which bakes the Geist stacks directly into the emitted utilities - overriding--font-mono/--font-sansdoes nothing. - Embedded fonts: the shell inlines Inter and JetBrains Mono 400 as
data:@font-face(CSP already allowsfont-src data:) under the app's registered family names (Inter_400Regular/JetBrainsMono_400Regular), because the guest document is isolated from the app shell and can't see its webfonts. 400 only - same as the app registers - so browsers synthesize bolder weights identically. - Canvas text (node labels, context bars, cost overlays, timeline ticks): the vendor hardcodes
"<size>px monospace"in everyctx.fontassignment, so a shell script patches theCanvasRenderingContext2D.prototype.fontsetter to rewrite the trailingmonospacefamily onto the interface font. Sizes are left untouched - canvas glyphs are HUD elements sized to their boxes. - Live values:
visualizer-panel.tsxsends a shell-levelotto-appearancemessage ({uiFontFamily, codeFontFamily, chatFontSize}, resolved bypackages/app/src/visualizer/visualizer-appearance.ts, mirroringapplyAppearanceincl. the compact +2 bump) on thereadyhandshake and live on appearance-settings changes. The shell script consumes it (sets the CSS vars,--otto-font-scale = chatFontSize / 10); the vendor bridge ignores the unknown type. User-custom families resolve if installed on the machine and fall back to the embedded defaults.
The page follows the active app theme variant (all 13, light and dark) instead of the vendor's fixed near-black/cyan hologram. Design intent, locked by visualizer-theme.test.ts:
- The stage is always darker than the app background - a step below even the sidebar - so the graph reads as its own space (user-locked). Dark variants go near-black (55% mix toward black); light variants stay unmistakably light (5% deepened paper) with dark nodes and glyphs.
- The vendor's holographic cyan becomes the variant's accent - glow, chrome, idle/thinking states, links - so every theme keeps a unique identity that matches the app.
- Semantic hues ride the theme's own semantic tokens:
statusWarningfor tool activity,statusSuccessfor completion/cost,statusDangerfor errors/live,statusMerged(purple) for thinking/reasoning/dispatch,diffAddition/diffDeletionfor diffs.
Mechanics:
- The seam is the vendor's own
COLORSregistry (web/lib/colors.ts, ~130 tokens - upstream deliberately centralized it). A vendor patch (OTTO-PATCHES.md) mergeswindow.__OTTO_THEME__.colorsover it at module init;packages/app/src/visualizer/visualizer-theme.tsbuilds the full overlay from the variant palette (buildVisualizerPalette, per-schemedarkProfile/lightProfile). - Format rules are load-bearing: vendor draw/component code appends 2-digit hex alphas to solid tokens (
COLORS.holoBase + '80',stateColor + '90') andwithAlphaappends to partialrgba(r, g, b,bases - every overlay value must keep its vendor token's exact shape. The theme test parses the vendor registry from source and asserts key-set + shape-for-shape coverage, so an upstream pull that adds/renames tokens fails loudly. - Baked per load, not live: the palette is substituted into the shell's
__OTTO_THEME_JSON__placeholder (applyVisualizerTheme, double-encoded for the shell'sJSON.parse("...")) because the page consumes COLORS at module init and React renders - there is no repaint path. A theme change therefore remounts the guest (same contract as the dpr cap), and the panel's handshake-reset effect keys on the palette JSON. - Variant resolution avoids
useUnistyles(): the panel resolves the active variant fromsettings.colorSchemeMode/lightTheme/darkThemeplus RNuseColorScheme()- exactly mirroringapplyColorScheme(whose variant tablesapply-color-scheme.tsnow exports) - and builds from the static variant theme objects. - Stylesheet-level chrome the registry can't reach (glass-card fills/borders/inputs/scrollbars in the vendor
globals.css) is themed by shell CSS overrides reading--otto-vis-*variables, set by the shell script from the palette'scssmap; every var falls back to the vendor value so the demo shell keeps the upstream look. - Host-side containers (iframe/webview backgrounds) paint the palette's stage color via the views'
themeBackgroundprop, so guest load/resize never flashes black on light themes.
A node spawned from a named Agent Personality renders in that personality's own two identity colors instead of the theme's default state color - but only in the two states where color is identity rather than activity: idle shows the colors muted, thinking shows them vivid. Every other state (tool_calling, waiting_permission, complete, error) keeps its prescribed semantic state color so activity stays legible, and an agent with no personality (e.g. one not started by Otto) is an unchanged state-driven node. The color source is free: the agent snapshot already carries the personality's colors as personalitySpinner {glowA, glowB} (the same pair that tints its thinking spinner), so there's no new protocol - the adapter just forwards them into the agent_spawn payload as colorA/colorB, and a live personality switch re-emits the spawn with the new tint (lastPersonaColorKey on the tracked node). Render side is a vendor patch (OTTO-PATCHES.md "personality-colored agent nodes"): resolveNodeAppearance in canvas/draw-agents.ts derives the muted/vivid accent (border/glow/scanline/glyph) and a two-stop gradient interior fill from agent.personaColor. Because it's a vendor patch, changes need npm run build:visualizer.
packages/app/src/visualizer/: visualizer-event-adapter.ts holds the pure, unit-tested mapping functions (Otto timeline/stream shapes → SimulationEvent); use-visualizer-event-adapter.ts owns the stateful side - node-name registry, backfill fetch, live-stream cursor dedup, batching (~200ms tick, matching the page's own UI throttle).
Sessions: one visualizer session per root agent in the workspace (session-started {session:{id: agentId, ...}}). Any agent spawned by another tracked agent - observed Task children (attend:"observed", parent::sub::key ids) AND attended create_chat children alike - does NOT get its own session: it rides inside the parent's session as a child node (agent_spawn {parent}), mirroring the subagents track. An observed child whose parent isn't in the tracked set yet is deferred (not registered as an orphan session) until a reconcile sees the parent.
All active: the toolbar prepends All active only when the workspace has a real chat with a live provider session. It selects the synthetic otto:all-active-chats view in the page bridge, which replays and streams those live sessions into one scene. initializing, idle, running, and recoverable error sessions remain live, so an unfocused chat with retained provider context stays in the forest; closed and archived chats are individually selectable historical records and never enter it. Draft tabs do not qualify. The roots remain separate, unconnected trees: root and child identity still comes from the real agent/session events, and only an explicit future agent-to-agent message event may create a cross-chat relationship. The first root stays centered for the ordinary one-chat case; further roots receive deterministic golden-angle seed positions, giving the force layout separate top-level islands rather than a stack at the origin. The page buffers both the normal per-session histories and an arrival-ordered all-session history, so switching to All active preserves the normal session picker and does not require a second renderer. Node names are unique across the workspace, rather than merely within a root session, because the vendor simulation keys agents by display name when all roots share a canvas.
Draft chat tabs are sessions too. The chats dropdown mirrors the workspace's chat tabs, not just live agents: a kind:"draft" tab (a chat that hasn't started an agent yet) is surfaced as an empty session - session-started keyed draft:<draftId> (see sessionIdForDraft), label "New chat", no events. A session with no events puts no agents on the canvas, so selecting it shows the page's "Waiting for chat activity" empty state. This is what makes /clear behave: /clear archives the current chat (→ close-session, the old timeline is gone) and opens a fresh draft in the same tab, and because the panel follows the focused tab (not the session store's focusedAgentId, which is null for a draft - chatSessionIdForTab in visualizer-panel.tsx), the Visualizer settles on the empty draft instead of freezing on the thrown-away chat. When the draft gets its first message it retargets to an agent tab: it leaves the draft set (→ close-session), and the real agent session appears via the normal path. Draft sessions live outside the per-activation adapter state (no events/backfill) and are reconciled by a lightweight effect that re-emits them after every reset+replay (the reset clears the page's session list); their startTime/lastActivityTime are pinned to 0 so an empty draft never out-sorts a live chat in the page's most-recent auto-select (the dropdown shows only the label).
Background chats never steal the canvas. The vendor page auto-selects every session-started it receives, which is right for the attach replay and wrong for everything after it: chats appear in a workspace without anyone switching to them (a queued task starting, a schedule firing, an orchestration fanning out), and each one yanked the view onto the new chat until follow-the-focused-tab dragged it back. Starting several tasks back to back made the Visualizer flick through them all while the user never left their tab. So session-started carries an optional select leaf: the adapter's pushSessionStarted stamps select: false on every start queued outside the hydration window, and draft sessions always send it (a draft tab opening is not a request to watch it). The page then registers the session (dropdown entry, buffered events, activity dot) without moving its selection, unless nothing is selected at all - an empty canvas takes the new chat rather than showing nothing. Selection stays the host's job: the focused-tab follow effect in visualizer-surface.tsx, or an explicit pick from the chats dropdown (which also pins). Replayed starts keep the old behavior, so orderSessionMessagesForSelection still settles the initial view on the most recently active chat. Vendor side: OTTO-PATCHES.md "background session starts do not steal selection", so it needs npm run build:visualizer.
Time base (per-session anchor): the page's simulation clock runs in seconds from ~0 (rAF dt accumulation), and every lifetime constant (TOOL_MAX_RUNNING_S etc.) plus the control bar's m:ss readout assumes that scale - so the adapter must stamp in relative seconds (feeding raw epoch-ms slammed the sim clock ~1.7e12 ahead per event; two events 200ms apart aged 200 "seconds" and running tools faded mid-run). Each event time is (epochMs - sessionAnchorMs) / 1000, where the anchor is that session's own start - the root agent's createdAt, recorded in AdapterState.sessionEpochMs when the root registers (toSimTime(state, ms, sessionId)). Anchoring per-session (not at adapter activation) is what gives backfilled history its real relative spread: a reopened chat's Execution Timeline and scrubber show the true shape of what you missed - a 17-second turn reads as 17 seconds - and a rewind lands on the session start instead of emptying the canvas to the "Waiting for chat activity" backdrop. Anchoring at activation instead clamped all history to ~0, collapsing it to a zero-width sliver. This is safe against the page's Math.max(event.time, currentTime) floor (use-agent-simulation.ts) because the page cold-restarts its sim clock to 0 on every session select (index.tsx session-switch useLayoutEffect), so a session's 0-based history always replays from clock 0 and spreads; the root's createdAt precedes all of its (and its subagents') activity, so every time stays >= 0. Session-message fields (startTime/lastActivityTime) stay epoch-ms - the page mixes those with its own Date.now().
One tool_call_start per callId: a long-running tool call streams repeated in-place updates of the same running item as output grows; re-emitting a start for each made every progress update spark a fresh outward-firing tool node on the page. The stateful adapter drops running updates for already-started callIds.
One bubble per streaming message (delta coalescing): the daemon streams assistant/reasoning/user text as deltas (Claude's emitNewContent slices off the already-emitted length; other providers chunk similarly), keyed by a stable messageId where the provider supplies one. Backfill hides this - fetchAgentTimeline requests projection:"projected", which merges the deltas into whole messages (timeline-projection.ts) - but the live path sees raw deltas, and the page's message handler appends a new floating bubble per event (its only dedup is "same text as the last bubble", which never fires on growing text). Emitting a message SimulationEvent per delta therefore drew dozens of tiny boxes for one message and forced the user to zoom out. The stateful adapter (streamingMessage on each TrackedNode) instead accumulates contiguous same-messageId deltas and emits one whole-message event when the message settles - a different message starts (key change), a tool call / non-message stream event (turn_completed, permission, …) intervenes, or backfill ends. This mirrors the projected-timeline merge but for the live path, and is host-only (no vendor patch, so no build:visualizer). Reasoning has no messageId, so it keys on the role and relies on the intervening-event settle. Trade-off: a message still streaming live at the exact moment a Visualizer attaches can split across the backfill→live boundary into two bubbles - rare and cosmetic, versus a bubble per delta before.
Completion / when a node fades: reconcileAgents emits agent_complete (which the page fades out non-main nodes on) from the pure isVisualizerAgentTerminal predicate, which mirrors the subagents track's isSubagentRowTidyEligible (subagents/track-presentation.ts) so a node leaves the graph exactly when the track collapses the row into "Completed": closed/archived always (roots included); for an observed subagent, also idle or error unless it's attention-flagged. This is load-bearing - a Claude Task ends its run at idle (not closed), so the old closed || archived-only test left completed Task nodes stuck active forever, and clearing them was flaky because the fade never fired. Terminal detection runs for freshly-registered nodes too, so a backfill that first sees an already-idle observed subagent still completes it. Attended/native agents idle between turns and are never faded on idle.
Archive is removal, not completion. Archiving the visualized chat is the one case reconcileNodeLifecycle splits off from the generic terminal path: a root whose archivedAt is set drives the page's close-session (→ removeSession) instead of agent_complete. Completion would leave the chat's node green + selected and play the completion chord - but the user threw the chat away, so its session should just disappear (the page returns to "Waiting for chat activity" or auto-selects a remaining chat). A non-archived root that reaches closed still emits agent_complete, but it stays in the picker: a provider session can close while its chat transcript remains a durable record. Un-archiving while attached re-emits session-started + the spawn (TrackedNode.sessionRemoved tracks the flip); a background reset+replay rebuilds it anyway. Non-root nodes have no session of their own and keep fading via the normal terminal path. Page side (vendor patch): closing the last session had to be taught to clear the canvas - handleCloseSession now selectSession(null) when none remain, and the session-switch useLayoutEffect cold-restart()s the simulation on a null selection, so the graph actually empties to "Waiting for chat activity" instead of leaving the final agent frozen center-stage (OTTO-PATCHES.md, needs build:visualizer).
Backfill + liveness: on activation the adapter does a full reset + replay - fetches each new node's timeline via client.fetchAgentTimeline(agentId, {direction:"tail", limit:0}) as one agent-event-batch, then streams live agent_stream events, using an epoch/seq cursor to avoid double-feeding the backfill/live overlap. Every transition to active (page ready AND pane visible) triggers this same reset+replay - that's also how the adapter recovers from the hidden-webview rAF stall (below), since active flips to true again on tab refocus.
The visualizer must declare its own viewed-timeline subscription. Live agent_stream delivery is gated daemon-side by CLIENT_CAPS.selectiveAgentTimeline: Session.forwardAgentStream only forwards a timeline event for an agent the client has declared it is viewing (agent.timeline.set_subscription.request, unioned per source in viewed-timeline-sync.ts). The workspace screen's declaration (selectVisibleAgentIds) covers only the agent tab active in each visible pane - a visualizer tab contributes nothing there, and a visualizer showing every chat in the workspace needs far more than the one focused chat. So useVisualizerEventAdapter registers its own source (visualizer:<n>, one per mounted surface so the tab and the PIP never overwrite each other) holding every tracked node id, roots and their observed/attended children alike, since each streams under its own agent id. Without it the graph looked alive but was dead: nodes spawned from the agent snapshots and then never moved, and only leaving the pane and coming back appeared to fix it - that reattach is a full reset + replay, so the persisted rows the live path had missed all arrived at once. The set is published right after each reconcile and before the node's backfill fetch: live envelopes arriving while TrackedNode.cursor is still null are buffered rather than dropped, and the backfill cursor then dedups the overlap. The declaration is dropped on cleanup; the sync holds removals for a grace period, so toggling the pane does not churn the daemon subscription.
Backfill tail re-asserts the node's lifecycle state. Turn boundaries (turn_completed/turn_failed/turn_canceled) are LIVE-only stream events - they never land in the persisted timeline - so a replay always ends on the last assistant/tool item, which leaves the node pulsing 'thinking'. backfillAgentTimeline therefore ends every replay by re-asserting where the node actually is, keyed off TrackedNode.lastStatus (the freshest store status, updated on every reconcile): an already-terminal node (terminalEmitted, unless archived → removed) gets a trailing agent_complete so the page's last word on a finished agent is its completion; a non-terminal idle node gets a trailing agent_idle {resting: true} - this is the only mechanism that settles a reopened idle root/attended chat at 'idle' (roots never fade on idle, so nothing else would ever rescue it from 'thinking'). A running agent gets neither: it is genuinely mid-turn and the live turn_completed idles it later. Regression tests for both tails live in use-visualizer-event-adapter.test.tsx ("re-asserts completion…" / "settles a reopened idle chat…").
Hydrate on attach (settle, don't re-play). Because the page bumps every incoming event's time to now (use-agent-simulation.ts), that backfill replay would otherwise animate the whole run back in at once - every spawn burst, tool spark, and message bubble firing again, with sound - each time you bring an existing chat into view. The user expects the state they came into, not a re-play. So the adapter tags the backfill: AdapterState.hydrating is true for the whole initial hydration window, and flush marks every batch sent in that window agent-event-batch { hydrate: true } (a new optional leaf on the host→page message). The window closing is load-bearing and was a source of a "replays on first open" regression: hydrating must stay true until the authoritative refreshAgentDirectory has resolved and the backfill queue is drained with nothing in flight - not merely until the first flushAfterBackfill resolves. That earlier one-shot flip raced the directory refresh: an agent that first appeared via the refresh (a chat that hadn't synced into the store yet, or an observed subagent) backfilled after the flip and streamed out un-hydrated, so the page animated its whole history back in. The drain is single-flight (AdapterState.backfillDrain, an in-flight promise a second caller awaits rather than starting a rival loop) because the hydration sequence and the store-subscribe reconcile both drive it; the flip only happens once that promise settles and the queue is quiescent. The vendor page settles a hydrate batch to its end state instead of animating it - settleVisualState (a time-independent counterpart to the scrubber's snapVisualState, since the replayed history is all stamped ~t0) drops every finished tool card / message bubble / particle, keeps only still-running tools ("what are they doing right now"), and sets node opacities to their settled values so the canvas's own frame-diff detector fires no spawn burst; a suppressAudioRef one-shot mutes that frame's sounds. The event content still flows through the handlers, so the per-node chat panel and the timeline keep the full history - only the transient canvas animation + sound are skipped. Everything after the window is genuinely-watched activity and animates normally. Because the settle keys on the per-event hydrate flag (carried through the normal buffer + pending paths), it lands whether an event reaches the sim live or via a buffered session replay - no dependence on session-switch timing. Vendor side: OTTO-PATCHES.md "settle backfilled history on attach". This is a vendor patch, so it needs npm run build:visualizer.
Node names must be stable and unique per session - the page keys agents by name; resolveAgentNodeName disambiguates collisions with a short id suffix.
Synthesized tool_call_start for coalesced fast tools: the daemon's agent-stream-coalescer.ts replaces a buffered running tool-call entry in place (by callId) when the terminal status lands inside its ~60ms flush window, so a fast tool call reaches the client - live AND persisted-for-backfill - as a single terminal item with no preceding running item. The page silently drops a tool_call_end with no running match, which would hide most quick Reads/greps/small edits from the graph. The stateful adapter therefore tracks per-node startedToolCallIds and asks the pure mapper (synthesizeToolCallStart) to prepend the start (and, for sub_agent calls, the subagent_dispatch) when a terminal item's callId was never seen running.
Dispatch/return child labels must equal the observed node name. The page renders subagent_dispatch/subagent_return as particles on the parent→child edge keyed by child name - a label that doesn't exactly match the child node's name targets a non-existent edge and silently never renders. The child node is named by the daemon-frozen observed row title, so both sides share one rule: deriveObservedSubagentTitle in @otto-code/protocol/observed-subagent-title (prefer a non-generic subAgentType, else the description). Because a sub_agent tool call's input streams progressively (the first running item can carry description before subagent_type has parsed), the stateful adapter re-emits the dispatch whenever the derived label changes for a callId (subAgentDispatchLabels) - only the label-accurate dispatch ever renders.
Context ring: the page draws the main node's context-window ring from context_update events only. The live source (turn_completed usage) fires once per turn and never during backfill, so the adapter also emits a context_update from reconcileAgents whenever an agent snapshot's lastUsage.contextWindowUsedTokens moves - a freshly-attached visualizer shows the ring immediately instead of waiting for the next turn to end.
The ring's colored segments come from the same accounting the context meter shows. context_update.breakdown is { segments: [{ label, tokens }] } - an open-ended labeled list, not a fixed category enum, because the authoritative source is the provider's own reported split and every provider names its categories differently. buildContextBreakdown (visualizer-event-adapter.ts) resolves exactly one breakdown per agent, in this preference order:
lastUsage.contextCategories- the provider's own split, refreshed at each turn boundary byAgentManager.refreshContextCategoriesfromAgentSession.getContextUsage(). This is the same call theagent.context.get_usageRPC serves the chat footer's context meter and Context Management, so the graph and the meter can no longer disagree about one agent. Implemented by Claude (from the SDK's own accounting) and the OpenAI-compatible provider (which owns its whole prompt, so it counts locally). The refresh is deliberately fire-and-forget: a display read must never delay or fail a turn, so it re-emits state when the reading lands.lastUsage.contextComposition- the daemon's coarse 5-way estimate over its own agent timeline (context-composition.ts), mapped to the same display labels. This is the tier for Codex, Copilot and OpenCode, which report context occupancy only (a total and a model window) and never hand Otto a categorized split or the prompt itself. Provider-neutral by construction, since every provider populates the timeline.- Neither ⇒ no
breakdownand the page draws occupancy with no color - the honest degrade.
Segments are scaled to the authoritative occupancy (the ring draws each arc as tokens / tokensMax), so the colored parts always total the x / y tokens label beside them. Deferred categories are excluded: they are not counted in the window total, so folding them in would over-fill the ring. Segment colors are derived from the label, not from a position in a struct - see the contextSegmentColor patch in OTTO-PATCHES.md.
Known approximation in tier 2 only: the timeline is full history while the live window is post-compaction, so the proportions can skew after a compaction; scaling to occupancy keeps the total honest. Tier 1 has no such skew - it is the provider's own reading of the live window.
Discovery cards: what the agents are finding, not just doing. A notable tool outcome renders as a floating labeled card near the node - file · pattern · finding · code · error, with a click-through detail popup. Colors encode the category, with one outcome exception: failure-shaped discoveries (failed commands, failed tests) are typed error and render in the themed error red, so a failure never wears the green finding color while the tool-call card beside it is red. The whole render subsystem (draw, hit-detection, popup, theming, the DISCOVERY_HOLD_S fade, settle-visual-state hydrate handling) shipped in upstream's web/ and the mock scenario emits discovery on tool_call_end, but no vendor handler ever consumed it - nothing wrote state.discoveries, so the array stayed empty and cards never rendered, demo included. Otto supplies both halves:
- Host side:
deriveToolCallDiscovery(detail, { workspaceRoot, isError })invisualizer-event-adapter.ts- pure, unit-tested, emitted aspayload.discoveryon the existingtool_call_endevent (payloadisRecord<string, unknown>, so no type change). Paths run throughrelativizeStringPathslike everything else in the graph. - Page side: the
pushDiscoverypatch inhandle-tool-events.tsvalidates the payload, fans successive cards around the node by the golden angle, and pushes aDiscoverythat animates out from the node centre. It reuses the existing draw / hit-test / popup / theming untouched.OTTO-PATCHES.md, needsbuild:visualizer.
The trigger is a heuristic on notable results, and the signal-to-noise decision is the whole design - not every tool becomes a card:
| Tool detail | Type | Card |
|---|---|---|
| Grep / Glob | pattern |
"N matches · M files" plus the top 3 paths |
| Web search | finding |
the query, plus the top 3 result titles |
| Write | code |
NEW: <relative path> plus line count and a 2-line peek |
| Edit | code |
the file, plus +N −M lines parsed from the unified diff |
| Bash (test run) | finding / error |
"Tests pass" (green) / "Tests failed" (red) plus the matching output lines |
| Bash (any failure) | error |
"Command failed" plus the command and exit code |
| Fetch | finding |
the URL's host plus the first 2 content lines |
Two exclusions are deliberate: Read (by far the most frequent tool - reads would spray low-value cards) and sub_agent (already represented as its own node and a dispatch/return particle). Everything else - plan, plain text, worktree setup, unknown - returns null. There is no rate limit beyond that; if noise shows up live, a per-node cap is the next lever rather than widening the exclusions.
Observed subagents need their timeline store state initialized daemon-side. They have no ManagedAgent, so nothing runs the registration path that seeds InMemoryAgentTimelineStore - without ensureObservedTimelineState (agent-manager.ts) every observed timeline append/fetch throws Unknown agent '<parent>::sub::<key>' and the subagent's transcript is silently lost everywhere at once: no live agent_stream, no backfill, empty pane. The visualizer symptom was a child node that spawned and completed with zero activity in between.
Nested fan-out renders as a tree. A subagent's own Task/Agent tool_use appears inside ITS sidechain; the Claude provider records child tool_use id → spawning key (observedParentKeyByToolUseId) and sends it as ObservedSubagentUpdate.parentKey, which the daemon resolves into the row's PARENT_AGENT_ID_LABEL (pointing at the spawning subagent's observed id instead of the root chat). The adapter's existing parent walk then wires leaf → branch → root. The subagents track still lists the whole subtree under the chat: selectSubagentsForParent walks parent chains transitively but only THROUGH observed intermediates (an attended child is its own chat; its children are not this chat's rows).
Rows can revive after settling - nodes must resurrect. Claude can hand a Task off to the background: the parent's tool_result returns (the daemon settles the row idle) while task events keep flowing and flip it back to running. The page may have already faded and deleted the node, so reconcileNodeLifecycle re-emits agent_spawn when a settled row reconciles back to non-terminal (spawn of an existing name is a reactivate). Related invariants: the vendor's parent→children completion cascade is patched out (a parent finishing must not kill live children - see OTTO-PATCHES.md), and the Claude provider sweeps announced-but-unsettled observed rows to idle at turn end (appendTurnEndObservedSubagentSweep, Workflow rows exempt) so a lost task_notification can't strand a node forever.
Refresh resync is daemon-backed. The graph is derived entirely client-side, but every input survives a page refresh on the daemon: agent list (now including in-flight observed rows - listObservedSubagentPayloads feeds fetch_agents), per-id timelines, and the live stream. A mid-run reload rebuilds the graph via the normal reset+replay; nothing about visualizer state is (or should be) persisted in the page.
The Visualizer toolbar's Use as chat background action places the shared renderer behind the selected chat. The workspace header remembers this placement alongside Tab and PIP. Background, PIP, and visualizer tabs are mutually exclusive in the active workspace; switching placements retires the old guest and uses the existing reset-and-replay path to hydrate the new one.
The chat background's overlay toolbar offers a Collapse to picture-in-picture button beside the conversation and tab controls. It switches directly to PIP and remembers that placement. Like the tab toolbar's PIP action, it is hidden on compact layouts where PIP is unavailable. The PIP hover controls provide the reverse Use as chat background action when the workspace has a chat or draft tab. It focuses the current chat (or the first available chat when a file or terminal is focused), closes PIP, and remembers background placement.
chat-visualizer-background.tsx owns a viewport-sized layer covering the whole
chat pane, including the Composer area and its bottom inset, outside the
conversation's scroll container. Scrolling moves only the messages.
The canvas changes size when the pane changes size, never when history grows.
Only the focused, visible chat mounts a background guest, including draft chats;
retained hidden chats do not render their own simulations. A visualizer tab in a
hidden workspace cannot close the active workspace's background.
The background follows its owning chat explicitly. Both selection and the event adapter resolve child chats through the same workspace root: attended and observed subagents share their parent's graph session. Selection waits for the guest's session registration before requesting that session, and retries when the session mirror changes.
When clicking the background hides the conversation, the focused root AI node
uses the Visualizer's native messageBubbles renderer to retain its latest
assistant reply. The bubble survives user prompts and tool activity, then the
next assistant reply replaces it. It is not a chat-card overlay or a second
message renderer, and regular tab and PIP Visualizer surfaces keep all message
bubbles suppressed. The native auto-fit camera includes the complete persistent
bubble footprint, so it opens fully within the visible canvas rather than
clipping at the pane edge. Hiding is a tab-scoped choice: sending from the
Composer, including the draft-to-agent transition, never restores the
conversation. The persistent bubble may consume up to 78% of the canvas height
(24 wrapped lines maximum), instead of inheriting the eight-line cap for
transient event hints.
The normal background opacity is 30%, with the existing translucent bubble
fills and fully opaque text. On web and Electron, pausing over clearly empty space
for 200 ms fades the conversation to 25% and raises the background to 80%.
The hover checks 10 px above and below the pointer, excluding short gaps between
messages/actions. There is no sideways clearance requirement, so side gutters
remain usable beside messages. Movement beyond 6 px restarts the
entry delay; small pointer jitter does not. Once active, moving within clear space
keeps the preview steady, while returning to content restores reading opacity
immediately. Scroll, pointer press, exit, and resize cancel pending previews.
Clicking empty space hides
the conversation and shows the background at 100%; clicking again, pressing
Escape, or using Show conversation restores it. Controls remain visible and
the Composer and its flyovers remain above the graph and usable. The Composer
gutter is transparent in this placement, while the input keeps its normal fill.
The entire Composer area, including its empty gutter and bottom inset, is excluded
from background hover and click gestures. Moving from the conversation background
into that area clears the hover hint and restores normal reading opacity.
The top and bottom chat edges fade the transcript itself to transparency with
ChatTranscriptMask, revealing the actual animated canvas. Painted seam gradients
are suppressed in this placement: even a stage-colored overlay can obscure the
stars and moving spotlight. The scrollbar gutter remains unmasked, and the outline
and jump controls sit outside the mask. The pane beneath the translucent guest
uses its stage color. Closing the background restores the normal chat seam gradients
without replacing the scroll container.
Native uses the explicit show/hide control without
depending on hover.
Hiding changes opacity and interaction/accessibility state, without unmounting the conversation or writing its scroll position. Message bubbles, text, links, controls, selections, and scrollbar/drag gestures do not trigger background toggles. The graph itself is passive in this placement; expand it to a tab to interact with its nodes. Like PIP, it has no visualizer toolbar or playback strip, including the Live indicator. The background profile also hides its HUD and panels, and keeps the existing rendering quality and software-rendering controls. Opacity does not reduce the simulation's rendering cost.
A small live Visualizer pinned to the top-right of the workspace content, so the graph stays glanceable while you work in the chat underneath. Mounted by visualizer-pip-host.tsx and drawn by visualizer-pip.tsx. It has no header button of its own - see "One button, two surfaces" below.
One canvas, never two - and never a reparent. The obvious two options both fail:
- Reparenting the existing guest is not available. The Electron guest is a
<webview>, a cross-process surface; moving that element in the DOM detaches and reattaches it, which makes Electron reload the guest. That isn't a hypothetical -visualizer-view.electron.tsxalready carries adid-start-loadingre-hide because a layout change does this today. A reparent would destroy exactly the simulation it exists to preserve. A moved<iframe>re-executes on web for the same reason. - Two live guests is the doubled per-frame cost (two sims, two star fields, two bloom pipelines) with no upside.
There is one active PIP or Visualizer tab renderer per app window. VisualizerWindowProvider owns the only PIP mount and supplies the active workspace to tab ownership. Inactive workspaces retain their Visualizer tabs, placement and targets, but allocate no guest. Returning to a workspace reinitializes its existing tab; opening a Visualizer elsewhere never closes it. openVisualizerTab retargets the existing tab within that workspace for another orchestration. Restored duplicates are reconciled within the active workspace only, with a render gate preventing duplicate guests before reconciliation completes. Chat background mode is the only placement exempt from singleton renderer ownership.
Inactive visualizers unload. Switching workspaces tears down the outgoing PIP before initializing its replacement. A tab that is covered, in a hidden workspace, or in a minimized app holds no guest. Inactive chat backgrounds also unmount their surfaces. Closing or changing placement releases the guest and its adapter subscriptions immediately, with no exit-fade overlap. Lightweight tab descriptors and settings remain; inactive simulations and guest renderers do not. The scene survives the handover: the adapter's reset+replay rehydrates from the session buffers and the page settles that history to its end state rather than animating it (see "Hydrate on attach").
One button, two surfaces - and the surface is sticky. All surface movement goes through use-visualizer-surface.ts. The workspace header holds a single Visualizer button: it opens whichever surface you last used (visualizerSurface, device-local, "tab" | "pip") and closes whatever is showing. Switching surfaces happens from inside the Visualizer - the tab toolbar's PIP control collapses to the viewport and writes "pip"; the PIP's expand control opens the tab and writes "tab". Merely closing either one leaves the memory alone, so "close the PIP, reopen" gives you the PIP back.
Mutual exclusion must be a transition, never a hidden gate. The original PIP button flipped visualizerPipOpen blind while VisualizerPipHost silently refused to render whenever a Visualizer tab existed. The moment a tab appeared by any route - the header button, the Runs "Visualize" action, a restored layout - the setting sat true-but-parked and the button became dead chrome: click, click, click, nothing on screen (fixed 2026-07-20). The render gate still exists to keep the one-guest guarantee frame-tight, but it is not what enforces the invariant: useReconcileVisualizerSurface (mounted by the PIP host) writes the setting back to false so the closed state is real rather than merely invisible. On a compact layout, where the PIP does not exist at all, a remembered "pip" falls back to the tab for the same reason - a toggle must never set state on a surface that cannot mount.
One implementation, two surfaces. VisualizerSurface (visualizer/visualizer-surface.tsx) is the whole thing - guest, adapter, theme, fonts, session following. visualizer-panel.tsx is now a thin pane wrapper around it (surface="tab"), and PIP renders the same component with surface="pip". The only divergence is chrome + camera framing, and it lives as data in visualizer-chrome-profile.ts so the two can't drift.
Draggable, and it stays in. The one real obstacle to dragging is that most of the PIP's area is a guest - pointer events over an Electron <webview> (or the web <iframe>) belong to the guest document, so the host never sees a pointerdown there. The fix is a transparent host-side layer stacked above the guest (use-visualizer-pip-drag.ts), the same trick the panel's load cover already uses; it also swallows clicks the guest would otherwise eat, which is what we want from a non-interactive viewport. Position is persisted as a 0..1 fraction of the free space (container minus the PIP's own size), not pixels: 1 means "pinned to the right edge", 0 "pinned to the left", anything between keeps its proportion. That is what makes it follow the edges on a window resize and makes clamping total - a fraction in [0,1] cannot produce an out-of-bounds pixel position at any container size. Size and position live in device-local settings (visualizerPipSize / visualizerPipX / visualizerPipY), written once on pointer release rather than per frame.
It never shows before it knows where it goes. The container is measured by an onLayout on the anchor, so on the first commit it is 0x0 - and a 0x0 container resolves every stored fraction to the top-left corner. Rendering against that seed made the PIP appear in the corner and jump to its saved position a frame later. The frame therefore stays unmounted until the anchor has a real size; the position is never provisional, so there is nothing to correct. Any future PIP geometry read from an async source belongs behind the same gate.
Fade in; unload immediately when inactive. A presence opacity in visualizer-pip.tsx fades the contents in after measurement when Appearance → Animations is enabled. There is no exit hold: it must never keep a guest alive alongside its replacement. A plain host View owns drag geometry, with a separate animated child for presence opacity. The animated style stays outside the Unistyles theme sheet (docs/unistyles.md). The separate fadeLayer opacity is the hover see-through, unrelated to presence.
Never on mobile. Not degraded - absent. VisualizerPipHost returns null on a compact form factor, and the tab toolbar drops its PIP control there: a floating viewport you drag around makes no sense on a single-pane phone layout where the chat already owns the screen. Crossing the breakpoint unmounts it live; the setting is untouched, so it returns when you cross back, and the Visualizer button still opens the normal full tab (the mobile answer).
Mounted once above the router. VisualizerWindowProvider lives in the app's provider tree. WorkspaceCenterContent registers only the currently visible workspace. The PIP portals into the window overlay plane; a workspace's display: none cannot hide such a portal, which is why retained workspaces must never own PIP mounts. Changing workspace keys the singleton host to release the old guest; switching chats within that workspace changes the scene through the existing follow-active adapter.
Framing. The tab's auto-fit constants (ANIM.viewportPadding 56, AUTOFIT_MAX_SCALE 3.2, retuned 2026-07-20) read wrong in a ~240x150 box in both directions: the padding eats most of the usable area, and the max scale lets a one-node graph burst the frame. PIP therefore sends its own profile via the new config.camera vendor patch (PIP_CAMERA_FRAMING); the tab sends no camera key at all, so its values are untouched.
Chrome. Top HUD only - no panels, no transport controls. config.hudBottomHidden (vendor patch) hides just the bottom play/scrubber bar, which config.hudHidden could not do alone (it is all-or-nothing). PIP's only controls are Otto-side: pin, size, expand-to-tab, close.
Hover makes it genuinely see-through. The fade applies to the whole fadeLayer - the frame's background and border and the guest - not to the guest alone. Fading only the guest composites it against the frame's own opaque surface color, which reads as a washed-out grey rectangle rather than transparency. The control strip is a sibling of the faded layer, not a child, so it stays fully opaque and clickable while everything beneath it goes transparent; one gesture both un-blocks the chat and reveals the controls. The fade is suppressed mid-drag - you need to see what you are aiming.
Pinning is the tab's pin. followActive is lifted into VisualizerSurface as an optional controlled prop. The tab leaves it uncontrolled (its toolbar drives it); PIP passes its own state down. One follow state, two places to toggle it - not two states that disagree.
Collision with the suggested-task chips is geometric, not z-index. The PIP is mounted above the chips' entire ancestry, so no zIndex on the card can out-paint it - a descendant can never beat an ancestor's later sibling. The card therefore insets itself by useVisualizerPipInset() so the two never overlap at all. Because the PIP moves, the inset follows it: only a PIP in the top band can collide with a top-anchored card, and it clears whichever side the PIP is parked on (a centred PIP gets no inset - shifting would only make it worse). CHAT_PANE_OVERLAY_Z still records the intended order for siblings that do share a container.
Which chat it shows comes from the layout store. use-workspace-chat-focus.ts. Follow-the-active-chat originally read useWorkspaceTabsStore's uiTabsByWorkspace / focusedTabIdByWorkspace - fields nothing writes: the Visualizer was their only reader, while every real tab open/focus/close goes through useWorkspaceLayoutStore (panes own their tabs and their focusedTabId). So switching chats never moved the Visualizer, in either surface, and Pin looked inert because there was nothing observable to freeze. Fixed 2026-07-20; if follow ever breaks again, check that store first.
Voice cues shipped as a Visualizer sub-feature and are not one any more - see docs/agent-profiles.md "Voice cues". They play app-wide whether or not a Visualizer surface is open, and whether or not the visualizer feature is enabled at all; the toggle lives in Settings → <host> → Agents. Nothing about them reads the graph.
Sound here means sound EFFECTS. visualizerSoundVolume / visualizerSoundMuted - the Sound slider and the in-page speaker button - drive the vendor page's own effects and nothing else. Cues are a separate audio channel with their own agentVoiceCuesVolume in Agents settings: two unrelated things (ambience for a graph you are watching vs. a notification that fires while you are somewhere else), so a level that suits one rarely suits the other, and muting the graph must not mute your notifications.
One thread still ties back here: the wire capability is still named visualizerVoiceCues. It is a server_info.features key, so it keeps its original name for protocol back-compat; only the client-side naming moved.
Lessons from live triage - reach for these in order before reading more code:
- Log the bridge traffic, read the browser console. A temporary
console.log("[vis-adapter]", …)in the adapter'sflush()shows exactly what the page receives (spawn/complete/tool/context events with times);read_console_messageson the app tab collects it. One live run of this beats hours of static analysis - the original "child shows nothing" bug was obvious in one event log (spawn + complete, nothing between). Note: dev console mirroring prints each line twice; that's not a double-send. - Grep the daemon log for ERROR early.
packages/desktop/.dev/otto-home/daemon.log- the timeline-store throw (Failed to process session event) was sitting there the whole time. - Drive runs via CLI, not the composer.
OTTO_HOME="$PWD/packages/desktop/.dev/otto-home" npx tsx packages/cli/src/index.js send --no-wait <agentId> "<prompt>"(Git Bash, repo root - thenpm run cliwrapper script is bash-only on Windows). Synthetic browser clicks into the composer are flaky, especially right after a daemon restart. - The dev daemon does NOT hot-restart on server source changes - restart the whole
otto-devlaunch config to pick up daemon edits. Metro hot-reloads app-side changes fine. - A mid-session HMR of adapter/panel code can wedge the guest handshake ("The Visualizer couldn't start") - full page reload fixes it; not a shipped-code failure mode.
- A scratch repo + subagent prompts make a controlled scenario. Task fan-out on demand: "spawn N subagents (Task tool, subagent_type X, description Y) that …" - add foreground
sleeps to keep nodes alive long enough to watch, but know the harness may background long sleeps (tool policy), which is itself a useful revive-path test. - Unit harnesses exist for both layers:
use-visualizer-event-adapter.test.tsxdrives the stateful adapter through the real session store with a fake client (renderHook + fake timers); the pure mapping layer isvisualizer-event-adapter.test.ts. Daemon-side observed behavior:agent-manager.observed-timeline.test.tsvia the private-ingest harness pattern. OTTO_DEBUG_WORKFLOW=1dumps the raw Claude SDK message identity. A standing env-gated log inlogRawMessage(providers/claude/agent.ts) - the one funnel upstream ofrouteSdkMessageFromPumpthat sees every message before Otto reclassifies or drops it - printstask_id/tool_use_id/task_type/workflow_name/subagent_type/task_description/parent_tool_use_id/isSidechainat info level for each message. Off by default (zero cost). Set it in the dev daemon's env (the probe lives in server source, so it doesn't run under a packaged daemon), thengrep OTTO_DEBUG_WORKFLOW packages/desktop/.dev/otto-home/daemon.log. Primary use: whether a Workflow run's internalagent()fan-out carries per-agent identity on the live stream - group sidechain messages byparent_tool_use_idand see whethersubagent_type/task_descriptionvary per child. See subagent-accounting.md.
An orchestration Run's "Visualize" action (runs-screen.tsx RunCard) retargets the workspace's Visualizer tab to that run's agent set instead of opening another visualizer:
- Tab target:
{ kind: "visualizer", runId?: string }. An absentrunIdshows the workspace; a present one scopes the single tab to an orchestration. Opening another run retargets that tab instead of creating another Visualizer. The target identity still carries the run so navigation addresses the requested content. collectRunAgentIds(run)(packages/app/src/hooks/use-runs.ts) builds the agent-id set: the conductor plus every phase's spawned candidates. Also backs the existing token-cost rollup (sumRunTokens).use-visualizer-event-adapter.ts'sagentIdFilter(aReadonlySet<string>, compared by reference - callers must memoize it on membership, not on the runs query array: everyruns.updatedpush replaces the array even when membership didn't change, sovisualizer-panel.tsxderives a sorted-joined id-string key first and only rebuilds the Set when that key changes; a fresh Set per push would reset + re-backfill the page on every status/progress update of a live run) restrictsselectWorkspaceAgentsto agents in the set OR whose resolved root (the same observed-subagent walkensureNodealready does) is in the set, so a run's spawned agents keep correct parent/child wiring even when the parent itself isn't literally one of the run's own ids.
Everything the graph shows is the agent's own account of what it's doing, and most of it is freeform strings - so the adapter is careful about which paths it rewrites. File paths are displayed relative to the agent's own working directory (AgentNodeContext.workspaceRoot, sourced from the tracked agent's cwd), with the root itself shown as ., so a Read of C:\Users\me\proj\packages\app\src\foo.ts shows as .\packages\app\src\foo.ts and a cd to the workspace root shows as cd "." - instead of an absolute host path the width-limited tool-node label / heatmap row truncates to C:\….
A single rewrite (relativizeStringPaths) handles both a structured file field (Read/Edit/Write detail.filePath, a real {"file_path": …} tool-input field) and a freeform string (shell command, search query): it replaces the known workspace root wherever it literally appears with . (cd "C:/…/proj" → cd ".", cat "…/proj/src/foo.ts" → cat "./src/foo.ts"), matching either separator style (case-insensitive for Windows drives) and requiring a path boundary after the root - a separator, a delimiter like a quote/space, or end-of-string - so the bare/quoted root collapses correctly and a sibling dir that merely shares the prefix (…/proj-backup) is never touched. It never tries to detect arbitrary paths - a full absolute root is effectively never a false-positive substring - so it can't mangle non-path prose. Files outside the workspace have no matching prefix and are left verbatim; message/reasoning bubbles are left entirely alone.
Separator fidelity is load-bearing. The output preserves whatever separators the path was authored with - a Windows path stays \, a POSIX path stays / - only the root prefix is swapped for .. The adapter deliberately does not run display paths through resolveWorkspaceFilePaths / normalizeWorkspaceFileLocation: those normalize \ → /, which is right for opening a file (the open route still does it) but wrong for display - it left the File Attention panel mixing backslash (out-of-workspace, verbatim) and forward-slash (relativized) rows. Windows paths stay Windows; POSIX stays POSIX.
If a path still renders absolute, the first suspect is a cwd that doesn't literally prefix what the tool emitted.
Both live in the pure adapter (visualizer-event-adapter.ts); no vendor build. The one vendor counterpart is the File Attention panel's canOpen gate, relaxed from startsWith('/') (which made every relative and every Windows absolute path non-clickable) to any non-empty path - see OTTO-PATCHES.md, needs build:visualizer.
The page's open-file {filePath, line?} message (sourced from tool-call telemetry's inputData.file_path - now workspace-relative for in-workspace files, absolute for those outside) is routed through normalizeWorkspaceFileLocation (mapping line → lineStart/lineEnd) into usePaneContext().openFileInWorkspace. It opens with disposition: "side" (not "main"): the Visualizer is a live canvas that doesn't share its pane well, so a file opened on top of it would cover the graph the user is watching. Side disposition opens the file beside the Visualizer - the same routing chat file links use (handleOpenFileFromChatInSidePane → resolveSideFileOpenPlacement), which reuses an already-on-screen pane (searching right, then left/down/up - every pane in a split is visible at once) and only splits a fresh pane when the source pane is truly alone. This matters for the Visualizer specifically: it opens as a split to the right of the chat, so its only neighbor is the chat pane on its left - the file lands there rather than carving out a third pane. Relative paths resolve against the workspace root downstream, so a relativized path still opens correctly.
- Electron CSP: the app-shell CSP is inherited by srcDoc/data iframes - Electron embedding must use
<webview>on its own partition (see Embed above). will-attach-webviewallowlist: the desktop main process (packages/desktop/src/main.ts) blocks every webview attach whose partition it doesn't recognize - silently: no error anywhere, the guest just never loads anddom-readynever fires (executeJavaScript/openDevToolsthen throw "must be attached to the DOM and dom-ready emitted"). Any new webview partition needs its own attach/harden/lockdown module (features/visualizer-webview.ts, mirroringfeatures/artifact-webview.ts) wired into bothwill-attach-webviewanddid-attach-webview. Main-process change ⇒ full app restart, not hot reload.- No-GPU machines can show a blank tab - and it used to be silent. On Linux hosts running the GPU software-rendering fallback (
--ozone-platform=x11 --use-gl=disabled,packages/desktop/src/gpu-fallback.ts), the visualizer<webview>guest has been observed never loading/painting. A guest that never loads emits nothing (nodom-ready, noready), and the panel's opaque load cover only fades post-ready- so the failure presented as a solid stage-colored tab with zero evidence anywhere. Diagnostics now make it loud: the desktop main process logs[visualizer-webview]attach / dom-ready / did-fail-load / renderer-gone lines plus a 20s never-reached-dom-ready watchdog (electron-log), and the panel shows a "couldn't start" state when thereadyhandshake doesn't arrive within 15s of the pane being visible (or the Electron view forwardsload-failed). When triaging such a machine, check first whether browser-pane/artifact webviews render there (same<webview>mechanism), then try software GL instead of GL fully disabled: launch the binary with--ozone-platform=x11 --use-gl=angle --use-angle=swiftshaderas real process argv plusOTTO_FORCE_GPU=1(to skip the marker relaunch that would append the default fallback flags) -OTTO_ELECTRON_FLAGS/appendSwitchis too late for the browser process's Ozone platform selection, which is why the CLI passthrough allowlist (packages/desktop/src/daemon/cli/passthrough.ts) lets these switches through. The render layer itself needs no GPU - it's all 2D canvas + DOM. When software rendering is active, the app force-disables bloom (the three-blur-passes-per-frame layer a CPU rasterizer can't afford): the desktop shell reportssoftwareRenderingviadesktop_get_runtime_info(isSoftwareRenderingActive, gpu-fallback.ts - marker or software argv),useIsSoftwareRenderingcaches it app-side,visualizer-panel.tsxsendsconfig.render.bloom: falseregardless of the setting, and the Settings → Visualizer bloom toggle shows off + disabled without clobbering the stored preference. - The
<webview>guest paints WHITE before its first frame. An Electron<webview>'s guestwebContentshas an opaque white base color that Chromium fills during the gap between attach and the guest's first paint - and being opaque, it occludes the themed background set on the<webview>element behind it, so a load/reload flashes white (worst on the software-rendering machines above).WebContentshas nosetBackgroundColorto change that base (it's only on window/view types), sovisualizer-view.electron.tsxinstead keeps the element atopacity: 0untildom-ready(re-hiding ondid-start-loadingfor reloads), letting the themed host container - now painted thethemeBackgroundtoo - show through the gap. This is separate from the panel's DOM load cover (visualizer-panel.tsx): that cover hides the default-HUD flash on the first handshake, but itsconnectedRefguard means it does not re-raise on a reattach-triggered guest reload (moving the pane in the tree detaches/reattaches the node → Electron reloads the guest), so the element-level opacity gate is what keeps those reloads from flashing white. The mirror-image case is teardown: releasing that cross-process surface on.remove()(tab close, or an html swap on theme/scale change) can clear the region white the same way, so the effect cleanup setsvisibility: hiddenon the element before removing it - detaching it from the compositor first lets the region fall back to the themed content behind it. - Electron
<webview>guests freezevh/vwunits at the initial guest viewport size -window.innerHeightupdates on resize but100vhnever recomputes. The vendor root is Tailwindh-screen w-screen, so without countermeasures the whole UI lays out in a phantom initial-size box. Two-part fix, both required:autosize="on"on the webview element (else the guest viewport itself can stick at its pre-attach size), and the shell CSS remapping.h-screen/.w-screento thehtml→body→#root100% chain, which does track resizes (emit-bundle.mjs). - Tailwind v4 source scanning misses the vendor tree: the vendor's
globals.cssdoes@import 'tailwindcss', and v4's auto-detection only scans the package it builds in (packages/visualizer) - notvendor/agent-flow/web. Without the@sourcedirective inpackages/visualizer/src/otto-globals.css, the build "succeeds" with ~9 KB of CSS and zero layout utilities: everyabsolute/flex/h-fullin vendor markup silently no-ops and the HUD collapses into document flow. If the page ever looks structurally broken after a build change, check the emitteddist/index.csssize (~24 KB healthy) before debugging anything else. - devicePixelRatio is capped in the shell via a placeholder (
emit-bundle.mjs__OTTO_DPR_CAP__, substituted per the Sharpness setting - see "config.render(Otto patch)" above): the vendor sizes its canvas backing store and bloom blur buffers by dpr, and at native 2× a maximized pane is a ~6M-pixel store with a 3-pass blur every frame - measured 14 FPS vs 52 at cap 1 and 25 at cap 1.5. Unsubstituted (the--demobuild) the placeholder parses NaN and falls back to 1. Tune with the dev-only in-guest FPS meter (visualizer-view.electron.tsx); disabling bloom (config.render) is the other big lever. - The Electron view loads the shell as a
data:URL, which Chromium caps at 2 MB. With the embedded fonts the encoded URL is ~1.2 MB; anything that grows the shell meaningfully (more font faces, more weights) must re-checkencodeURIComponent(html).lengthagainst that cap or the guest silently never loads. executeJavaScriptbeforedom-readythrows - even once the element is DOM-attached (isConnectedis not enough). The Electron view queuespostMessagepayloads untildom-readyand flushes them there; keep that queue when refactoring.- Hidden panes stop the world: the page processes events only inside
requestAnimationFrame, and hidden/occluded webviews don't fire rAF. Events keep buffering (per-session buffers are authoritative) and flush on session re-selection - the adapter's reset+replay-on-reactivation handles this; don't try to "fix" a frozen hidden pane, it's expected. - Paused-player event drop (upstream quirk): in
use-agent-simulation.ts, pending external events are captured and consumed before theisPlayingcheck - events arriving while paused are dropped from the live view (still recoverable from the session buffer via reset+replay). Don't fight it in the adapter. config {showMockData:true, mode:'replay', autoPlay:true}is how a host shows the built-in demo scenario (exit with{showMockData:false, mode:'live', autoPlay:false}). The bundle capability is retained, but the on-canvas "Load demo scenario" button was removed -visualizer-panel.tsxcarries a comment documenting how to re-surface it (post the config pair + gate the event adapter'sactiveoff while it runs).- knip:
knip.jsonhas per-workspace config; add an entry forpackages/visualizerif knip starts complaining about it.