diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..b4611ec
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,18 @@
+{
+ "name": "runloop",
+ "owner": { "name": "Runloop" },
+ "metadata": {
+ "description": "Runloop: a local flight recorder for coding agents.",
+ "version": "0.1.0"
+ },
+ "plugins": [
+ {
+ "name": "runloop-tracer",
+ "source": "./integrations/claude-code-plugin",
+ "description": "Record Claude Code sessions into a local Runloop world and replay them. Observe-only.",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "category": "monitoring"
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b8df6d7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v5
+
+ # Installs the pnpm version pinned in package.json ("packageManager").
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm typecheck
+ - run: pnpm test
+ - run: pnpm build
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6a2d645
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.runloop/
+.next/
+.next-*/
+out/
+coverage/
+*.tsbuildinfo
+next-env.d.ts
+.DS_Store
+.env
+.env.*
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..2bd5a0a
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..02b0696
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,60 @@
+# Changelog
+
+All notable changes to Runloop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Runloop has not had a release yet.
+
+## [Unreleased]
+
+### Added
+- **Claude Code tracer.** The plugin is in `integrations/claude-code-plugin` and the marketplace manifest in `.claude-plugin/marketplace.json`.
+ - Hooks are observe-only: async, with Stop, StopFailure and SessionEnd synchronous. They always exit 0 and print nothing.
+ - Events are spooled locally before sending, and flushed by later hooks.
+ - Client-side redaction, and a `--doctor` setup check.
+- **Ingest.** `POST /api/ingest/claude-code`:
+ - guards: per-user token file, no browser `Origin`, 1 MiB body limit, per-session rate limit;
+ - dedupe by semantic keys;
+ - a pure, late-tolerant mapper;
+ - an idle sweep;
+ - continuation segments after a restart.
+- **Event model** (schema v1, additive):
+ - traced runs (`mode`, `source`, an `external` adapter);
+ - `provenance` (`reported` / `inferred`) on the envelope;
+ - the `run.marker` and `agent.usage_reported` events;
+ - tool `category`;
+ - the `turn` and `delegated` task kinds;
+ - the `unobserved` failure type.
+- **World.** Tool categories choose stations. Environments are named Atrium, Observatory, Workshop, Test Chamber and Launch Bay. There are presets for Claude and up to 8 subagent robots.
+- **Studio: the Runloop Signature design system.**
+ - **Design:** dark and light tokens, self-hosted Geist fonts, a `src/ui/` component library and a command palette (⌘K / Ctrl-K).
+ - **Pages:** Overview, Live World, Traces, Integrations and Settings, with deep links (`?run=&view=&event=&agent=`).
+ - **Selection and search:** one selection shared by the world, timeline, inspector and URL; search and composable filters.
+ - **Run views:** a run metrics panel; a 2D Network graph and an agents table.
+ - **Traced runs:** traced badges, a provenance banner, inferred chips, and "not reported" values (never zero); live sessions open automatically.
+- **3D world refresh:**
+ - all colours from tokens, so the world follows the theme;
+ - lime only for active execution;
+ - fit to viewport, with camera presets (overview, focus, next error);
+ - collision-free labels.
+- **Tooling:**
+ - `scripts/screenshots.mjs` (`pnpm screenshots`) captures every page and key state in both themes at 1280, 1440, 1920 and 390 px. It adds `playwright@1.56.1` as a root devDependency.
+- **Docs:** `docs/architecture/overview.md`, `docs/architecture/event-model.md` and `docs/status/verification-report.md`.
+
+### Changed
+- The runtime's default port moved from 4317 to **4380**, because 4317 is the OTLP/gRPC port (DL-005). The Studio and the plugin default to 4380.
+- Boot recovery is mode-aware. Traced runs close open calls as `unobserved` (inferred) rather than `runtime_restarted`.
+- Control routes (`stop`, `pause`, `resume`) answer 409 for traced runs.
+- Studio URLs end in a slash (`/overview/`), so the static export works on plain static servers.
+
+### Fixed
+- `--doctor` now finds the plugin data dirs Claude Code actually uses (`runloop-tracer-inline` for `--plugin-dir`, and any other `runloop-tracer*` directory) when run from a shell.
+- The Studio no longer shows "Runtime offline" after a single slow health check. It needs two consecutive failures once it has been online.
+- In the 3D world, a selected incident label is no longer covered by the selected agent's label. A second required label shrinks before it may overlap.
+- **Tracer delivery:** the hook sends the spool oldest first in size-bounded batches, its own event last, and stops at its time budget; a rejected batch is split so one bad envelope cannot block the rest. If the spool cannot be written, the event is sent directly.
+- **Redaction:** more token shapes, credential headers and `key=value` pairs, URL passwords and shell password flags are masked, and each secret's literal value is masked wherever else it appears. Long strings are redacted before they are capped. Shell commands are stored as `sensitive`.
+- **Tracer state:** the idle sweep measures silence by arrival time; a result arriving after its call was closed by a sweep is still recorded; permissions are closed as denied (inferred) at `PostToolBatch` or the agent's next batch rather than the next tool call; a late `PermissionRequest` after the result records the approval; subagents are linked to the delegating call by description and type (inferred), with a "Delegation corrected" marker if the guess was wrong. The ingest token file is checked for 0600.
+- **Layer 1 performance:** the reducer folds batches in place (`transient`), tool calls live in a bucketed `IdMap`, and closing a call no longer scans every call; replay builds snapshots in linear time.
+- **Incidents:** a traced failure is no longer resolved by the end of a turn. It resolves on a later successful test run or the same tool call succeeding again, labelled "Resolved (inferred)".
+- **Studio replay:** stepping, seeking and deep links move by event, not by time, so events in the same millisecond can each be selected; `at=` keeps a paused playhead in the link. Late live events no longer move a paused replay. Refresh keeps the selected agent card.
+- **Studio lists:** the timeline and logs are virtualised above 150 rows and are one Tab stop (arrow keys move between rows). A selected event hidden by a filter shows a "Show it" hint. Chips on a selected row keep AA contrast; tab focus rings are no longer clipped; the smallest text is 12 px.
+- **Studio status:** an unfinished (interrupted) subagent shows "unfinished", not "working"; the inspector names the agent of the selected event; "Paused" is an info state; a finished run settles on its end without a spurious replay.
+- **Network graph:** at narrow widths the graph uses smaller cards or a top-down layout instead of scaling the text down; a wrapped layer's sub-columns leave room for message labels.
+- **Logs:** the agent column lines up on rows with and without chips.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7d0a3b1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 The Runloop Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 3e7248c..c159772 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,121 @@
-# runloop
\ No newline at end of file
+# Runloop
+
+A local flight recorder for coding agents. Runloop records every event of an agent run in an append-only log on your machine, shows the run live as a small 3D world with a timeline and an inspector, and replays any moment without executing anything again.
+
+
+
+**Status: prototype.** It records two sources today:
+- **Claude Code sessions**, traced through a plugin. Runloop records what Claude Code reports and never runs or approves anything itself.
+- **A scripted demo mission**, executed by Runloop's own runtime with real tools.
+
+Run comparison (baselines and diffs) is the next milestone; see [TASKS.md](TASKS.md).
+
+## Quickstart
+
+You need Node 22.16 or newer and pnpm 10.
+
+```sh
+pnpm install
+pnpm dev # runtime API on http://127.0.0.1:4380, Studio on http://localhost:3000
+```
+
+Open http://localhost:3000 and click **Run demo mission**. Three scripted agents survey a small repo, implement a task store and run its test suite. The first test run really fails (10 passed, 2 failed). The Builder then patches the bug, and the rerun passes 12/12. Click a timeline row or a robot to inspect its events. Drag the replay bar to see any earlier moment.
+
+Runs are stored in `.runloop/` at the repo root (set `RUNLOOP_DATA_DIR` to use another directory). They survive runtime restarts and replay from the log.
+
+## Trace Claude Code
+
+With the runtime running (`pnpm dev` or `pnpm serve`), start Claude Code with the plugin from this checkout:
+
+```sh
+claude --plugin-dir ./integrations/claude-code-plugin
+```
+
+Or install it from the marketplace, inside Claude Code:
+
+```
+/plugin marketplace add argahv/runloop
+/plugin install runloop-tracer@runloop
+```
+
+Install it only once: with both a checkout and a marketplace copy, every event is sent twice.
+
+Use Claude Code as usual. The session appears in Studio live, with:
+- a robot for the main agent and one for each subagent (up to 8);
+- tool calls at stations by kind (reading in the Observatory, edits and commands in the Workshop, tests in the Test Chamber);
+- failing commands as incidents, with the reported exit code and output summary;
+- subagent token usage as Claude Code reports it. The main agent's usage shows as "not reported", because hooks do not expose it.
+
+To check the setup, run `node integrations/claude-code-plugin/scripts/runloop-hook.mjs --doctor`. It prints the runtime URL, the token file, whether the runtime answers, the spool and the last error.
+
+| Claude Code session | Failing test as an incident | Network view (2D) |
+|---|---|---|
+|  |  |  |
+
+These screenshots come from a real `claude -p` session (Haiku) traced end to end; see the [verification report](docs/status/verification-report.md).
+
+**What is collected.** The plugin sends these to the local runtime only:
+- the session id;
+- prompts and Claude's final replies (marked sensitive);
+- tool names and summarised inputs;
+- result sizes and counts, plus the tail of command output, used to read test counts;
+- the model and token usage Claude Code reports for subagents;
+- the project's base name (never its absolute path).
+
+File contents, diffs, full command output and transcripts are never collected. Well-known credential formats (API keys and tokens, URL passwords, `Authorization` headers, `password=…` style assignments, `curl -u`, `mysql -p`) and credential-like environment variables are masked in the plugin and again in the runtime; a secret in a form not on that list is stored as is, so shell commands are marked sensitive. The exact list, settings and opt-outs are in the [plugin README](integrations/claude-code-plugin/README.md).
+
+**Trust labels.** Every traced event is marked *reported* (Claude Code said it) or *inferred* (Runloop reconstructed it). An example of an inferred event is a denied permission, which fires no hook. The Studio shows these labels and never presents a traced result as verified.
+
+## Commands
+
+```sh
+pnpm dev # runtime + Studio
+pnpm serve # runtime only (port 4380; --port to change it)
+pnpm demo # run the demo mission headless and print one trace line per event
+pnpm test # unit, integration and end-to-end tests (vitest)
+pnpm typecheck
+pnpm build # static export of the Studio (apps/studio/out)
+pnpm screenshots # visual regression screenshots of every page (needs a runtime and Studio running)
+```
+
+## How it works
+
+Three layers stay strictly apart:
+
+1. **Execution truth.** The append-only event log. Tool results come only from tools that really ran (executed runs) or from what the source reported (traced runs).
+2. **World simulation.** A pure function of the log and a world definition: where each robot is and what it is doing.
+3. **Presentation.** The Studio draws layer 2 and never writes back to it.
+
+Live view and replay fold the same events through the same reducers, so they always agree. More detail:
+- [Architecture overview](docs/architecture/overview.md)
+- [Event model](docs/architecture/event-model.md)
+- [Claude Code tracer design](docs/plans/claude-code-tracer.md)
+- [Design system](docs/design/design-system.md)
+- [Current state](docs/status/current-state.md)
+
+## Security notes
+
+- The runtime listens on loopback only. Ingest from the plugin needs a per-user token (`~/.config/runloop/ingest-token`, created by the runtime) and rejects browser requests.
+- **Read endpoints are not authenticated.** Any local process of yours can read recorded runs, including prompts and commands, just as it can read the database file.
+- In the demo, `run_command` executes the workspace's tests as your user, without OS-level isolation. That is safe for the fixed demo code. Read the sandbox notes in [packages/runtime/README.md](packages/runtime/README.md) before connecting an adapter that writes its own code.
+
+## Repository layout
+
+```
+apps/studio Next.js Studio: Overview, Live World (3D + Network), Traces, Integrations, Settings
+packages/events event schema, parsing, redaction, SequenceBuffer, HTTP/SSE and ingest contract
+packages/core layer 1: execution reducer, selectors, text formatting
+packages/world-engine layer 2: world definition, grid paths, motion, character intents
+packages/replay combined reducer and ReplayIndex (state at any sequence or time)
+packages/runtime local runtime: SQLite recorder, tool sandbox, run manager, Claude Code ingest, HTTP + SSE API, CLI
+packages/testkit fixtures and helpers for tests
+integrations/claude-code-plugin the Claude Code plugin (observe-only hooks, spool, --doctor)
+worlds/research-campus the default world (rooms, stations, presets)
+examples/coding-mission the demo mission and its example repo
+scripts/screenshots.mjs visual regression screenshots (Playwright)
+docs/ strategy, architecture, plans, design system, status
+```
+
+## License
+
+Apache-2.0. See [LICENSE](LICENSE).
diff --git a/TASKS.md b/TASKS.md
new file mode 100644
index 0000000..4494af2
--- /dev/null
+++ b/TASKS.md
@@ -0,0 +1,86 @@
+# Task ledger
+
+**States:** Not started · In progress · Blocked · Implemented, unverified · Verified · Deferred.
+
+A task is **Verified** only when its acceptance criteria were exercised and the evidence is recorded in `docs/status/current-state.md` or `docs/status/verification-report.md`.
+
+## Milestone A: Foundation
+
+| ID | Task | Status | Evidence / location |
+|---|---|---|---|
+| A1 | Event contract, Layer 1, Layer 2 and the replay engine | Verified | `packages/{events,core,world-engine,replay}`; 182 tests (2026-10-17, after the review fixes) |
+| A2 | Runtime: recorder, sandboxed tools, API, SSE, recovery | Verified | `packages/runtime`; integration tests; manual SIGINT/SIGKILL |
+| A3 | Studio with live view, replay and the 3D world | Verified (prototype quality) | `apps/studio`; Playwright screenshots |
+| A4 | Move the runtime port 4317 → 4380 (DL-005) | Verified | `DEFAULT_RUNTIME_PORT = 4380` (events), Studio and plugin defaults; the B6 runtime ran on 4380 with no `--port` |
+| A5 | Real command sandbox for `run_command` | Deferred | Only needed before untrusted adapters execute commands; documented in known limitations |
+
+## Milestone B: Claude Code tracer (the first real integration)
+
+Plan: `docs/plans/claude-code-tracer.md`. It depends on A4.
+
+| ID | Task | Status | Acceptance criteria |
+|---|---|---|---|
+| B0 | Probe real hook payloads (M0) | Verified | Two real `claude -p` sessions logged; findings in plan Appendix C |
+| B1 | Schema: traced run mode, `external` adapter, provenance, `run.marker`, tool category, `turn`/`delegated` kinds, `unobserved` failure, usage events | Verified | Additive only; old logs parse; demo golden trace unchanged |
+| B2 | Pure mapper (hook payload → event drafts) with golden tests from sanitised real fixtures | Verified | Every fixture folds with no unexpected anomalies; one terminal event per call; subagents attributed by `agent_id` |
+| B3 | Ingest endpoint, token file, session manager, dedupe, idle sweep, mode-aware recovery, migration | Verified | 401 without token, 403 with an Origin, 413, idempotent retries, concurrent sessions in separate runs, scripted runs unaffected |
+| B4 | Plugin (`integrations/claude-code-plugin`): async hooks, spool, `--doctor` | Verified | `claude plugin validate` passes; the script always exits 0 with empty stdout; offline spool then flush produces the same events |
+| B5 | Studio: traced badge, provenance banner, inferred chips, live sessions, dynamic subagent robots, tool category → station | Verified | A traced session renders live; provenance is visible; every character is placed at a valid station |
+| B6 | **End-to-end acceptance with real Claude Code** | Verified | A real `claude -p` session with the plugin produces a live run in Studio with the main agent plus subagent robots, a failing Bash test shown as an incident, survival across a page refresh, and replay without re-execution. An automated test covers the fixture path; the real path is recorded in the verification report |
+
+Still unverified with real sessions (outside B6's criteria): interactive approvals and denials, Esc interrupts, `/compact`, `/clear`, resume and the marketplace install. See `docs/status/current-state.md`.
+
+**Review fixes (2026-10-17).** 27 findings from the tracer, design and state reviews were fixed or narrowed; each is listed in `docs/status/verification-report.md` §5. They were verified with unit and fixture tests, synthetic hook runs and Studio screenshots, not with new real Claude Code sessions. B2–B4 stay Verified on that basis; the new permission and delegation inferences have only synthetic coverage.
+
+## Milestone C: Compare (the retention feature)
+
+| ID | Task | Status | Acceptance criteria |
+|---|---|---|---|
+| C1 | Pin a run as a baseline; pick any two runs to compare | Not started | Survives a restart (persisted) |
+| C2 | Run diff: outcome, duration, tool calls by category, files touched, test results, tokens and cost where reported, permission waits, failures | Not started | Unit tests on the diff function; unknown values show as "not reported", never as zero |
+| C3 | Compare view in Studio (side by side, with links to the differing events) | Not started | Clicking a difference opens the event in that run |
+
+## Milestone D: Runloop Signature design system and core observability
+
+Spec: `docs/design/design-system.md` (founder brief). This milestone runs in parallel with B; B5 is built on top of it.
+
+| ID | Task | Status | Acceptance criteria |
+|---|---|---|---|
+| D1 | Tokens (dark and light), Geist and Geist Mono self-hosted, tabular numerals, motion tokens, reduced motion | Verified | `tokens.css` is the only colour source; contrast checked at AA in both themes |
+| D2 | Component library `src/ui/` (sidebar, tabs, split and resizable panels, command palette, chips, skeletons, empty, error, toast, confirm) | Verified | Keyboard reachable; used by every page; no colour-only state |
+| D3 | IA: Overview · Live World · Traces · Integrations · Settings (Experiments and Evaluations hidden until they work); deep links | Verified | Each page has loading, empty and error states; deep link restores run, view, event or agent card, and a paused playhead (`at=`) |
+| D4 | Synchronised selection across world, timeline and inspector; resizable inspector | Verified | Selecting in any view focuses the same entity in the others (tested in the store) |
+| D5 | World refresh: Signature palette, named environments (Atrium, Observatory, Workshop, Test Chamber, Launch Bay), lime only for active execution, fit to viewport, no label collisions | Verified | No clipped rooms at 1280–1920 px; labels never overlap at default zoom; screenshots in both themes |
+| D6 | 2D agent graph ("Network") view and a table fallback | Verified | Every world interaction has a 2D route |
+| D7 | Search and filter in the timeline and logs (agent, tool, category, status, text) | Verified | Filters compose; URL-addressable; tested |
+| D8 | Run metrics panel (duration, calls by category, failures, tokens and cost where reported) | Verified | Defined calculations; "not reported" never shown as zero |
+| D9 | Visual regression screenshots of every page and state | Verified | Script in the repo; images checked on each milestone |
+| D10 | World richness pass: the world fills its panel (tighter framing, collapsible timeline), robots read clearly at default zoom, warmer key/rim lighting and materials, restrained zone accent light; keep the Signature restraint | Not started | At 1440×900 a robot is ≥ 28 px tall at default zoom; the board fills ≥ 80% of the world panel width; founder-reference look (diorama, chibi robots) recognisable in screenshots of both themes |
+
+## Milestone E: Generalisation
+
+| ID | Task | Status | Acceptance criteria |
+|---|---|---|---|
+| E1 | Documented adapter contract and event-model docs | In progress | A new integration can be written from the docs alone |
+| E2 | Codex CLI hooks integration (DL-004) | Not started | Real Codex session traced end to end, or fixture-verified if Codex cannot run here |
+| E3 | Minimal TypeScript SDK for custom agents | Not started | An example agent emits a traced run |
+
+## Milestone F: Productisation
+
+| ID | Task | Status |
+|---|---|---|
+| F1 | Threat model, data-flow and retention docs; delete-run API | Not started |
+| F2 | Contribution guide, security policy, changelog | In progress |
+| F3 | Reproducible demo script (real Claude Code + scripted fallback, clearly labelled) | Not started |
+| F5 | Marketing landing page in the Signature system ("Execution, made visible.") with a real recorded run | Not started |
+| F4 | Performance measurements: ingest throughput, event-to-UI latency, large traces | In progress: first numbers on a synthetic 18.6k-event trace (labelled synthetic) in `docs/status/verification-report.md` §5; no CI benchmark, no real large session |
+
+## Deferred (with reason)
+
+| Item | Reason |
+|---|---|
+| Headless A/B runner (`runloop ab`) | Needs compare (C) first; costs real tokens per run |
+| OTLP/HTTP ingest bridge | OTel GenAI conventions are still in development; build on demand |
+| OpenAI Agents SDK / LangGraph adapters | Different user (agent builders); revisit when demand appears |
+| Hosted sharing, teams, billing | No validated demand yet; see the monetisation hypothesis |
+| Multi-agent simulation experiments | Keep simulation separate from captured traces; after compare |
diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts
new file mode 100644
index 0000000..b16d6b5
--- /dev/null
+++ b/apps/studio/next.config.ts
@@ -0,0 +1,25 @@
+import path from 'node:path';
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ output: 'export',
+ // Emit overview/index.html (not overview.html) so every static file server resolves the routes.
+ trailingSlash: true,
+ reactStrictMode: true,
+ // Don't (re)generate an AGENTS.md file on `next dev`.
+ agentRules: false,
+ // The dev badge would cover the sidebar's runtime status.
+ devIndicators: false,
+ transpilePackages: [
+ '@runloop-world/core',
+ '@runloop-world/events',
+ '@runloop-world/replay',
+ '@runloop-world/world-engine',
+ ],
+ turbopack: {
+ root: path.join(import.meta.dirname, '../..'),
+ },
+ images: { unoptimized: true },
+};
+
+export default nextConfig;
diff --git a/apps/studio/package.json b/apps/studio/package.json
new file mode 100644
index 0000000..14dad15
--- /dev/null
+++ b/apps/studio/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@runloop-world/studio",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "next build",
+ "typecheck": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "@react-three/drei": "10.7.9",
+ "@react-three/fiber": "9.8.2",
+ "@react-three/postprocessing": "3.2.0",
+ "@runloop-world/core": "workspace:*",
+ "@runloop-world/events": "workspace:*",
+ "@runloop-world/replay": "workspace:*",
+ "@runloop-world/world-engine": "workspace:*",
+ "geist": "1.8.1",
+ "next": "16.4.2",
+ "postprocessing": "6.39.5",
+ "react": "19.3.1",
+ "react-dom": "19.3.1",
+ "react-resizable-panels": "4.14.5",
+ "three": "0.186.1",
+ "zustand": "5.0.15"
+ },
+ "devDependencies": {
+ "@runloop-world/testkit": "workspace:*",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@types/three": "0.186.0"
+ }
+}
diff --git a/apps/studio/src/app/globals.css b/apps/studio/src/app/globals.css
new file mode 100644
index 0000000..d93e5ef
--- /dev/null
+++ b/apps/studio/src/app/globals.css
@@ -0,0 +1,134 @@
+/* Base styles. Colours, type, radii and motion come from tokens.css only. */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+}
+
+body {
+ background: var(--rl-canvas);
+ color: var(--rl-text);
+ font-family: var(--rl-font-sans);
+ font-size: var(--rl-text-sm);
+ line-height: 1.5;
+ /* Tabular numerals everywhere: timestamps, sequences, durations and counts line up. */
+ font-variant-numeric: tabular-nums;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+::selection {
+ background: var(--rl-selection);
+}
+
+h1,
+h2,
+h3,
+h4,
+p,
+ol,
+ul,
+dl,
+dd,
+figure {
+ margin: 0;
+}
+
+ol,
+ul {
+ padding: 0;
+ list-style: none;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+ color: inherit;
+ letter-spacing: inherit;
+}
+
+button {
+ cursor: pointer;
+ background: none;
+ border: 0;
+ padding: 0;
+}
+
+button:disabled {
+ cursor: not-allowed;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+:focus-visible {
+ outline: none;
+ box-shadow: var(--rl-focus-ring);
+}
+
+code,
+pre,
+kbd,
+samp {
+ font-family: var(--rl-font-mono);
+ /* Slightly smaller than the surrounding text, never below the 12 px floor. */
+ font-size: max(0.92em, var(--rl-text-2xs));
+}
+
+pre {
+ margin: 0;
+}
+
+/* Thin, quiet scrollbars. */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--rl-line-strong) transparent;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.mono {
+ font-family: var(--rl-font-mono);
+}
+
+/* Reduced motion keeps every state change, only without the travel. */
+@media (prefers-reduced-motion: reduce) {
+ :root:not([data-motion='full']) *,
+ :root:not([data-motion='full']) *::before,
+ :root:not([data-motion='full']) *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+:root[data-motion='reduce'] *,
+:root[data-motion='reduce'] *::before,
+:root[data-motion='reduce'] *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+}
diff --git a/apps/studio/src/app/integrations/page.tsx b/apps/studio/src/app/integrations/page.tsx
new file mode 100644
index 0000000..dc5bc40
--- /dev/null
+++ b/apps/studio/src/app/integrations/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import IntegrationsPage from '../../components/pages/IntegrationsPage';
+
+export const metadata: Metadata = { title: 'Integrations' };
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/studio/src/app/layout.tsx b/apps/studio/src/app/layout.tsx
new file mode 100644
index 0000000..dadad16
--- /dev/null
+++ b/apps/studio/src/app/layout.tsx
@@ -0,0 +1,36 @@
+import { GeistMono } from 'geist/font/mono';
+import { GeistSans } from 'geist/font/sans';
+import type { Metadata, Viewport } from 'next';
+import type { ReactNode } from 'react';
+import AppShell from '../components/AppShell';
+import { THEME_INIT_SCRIPT } from '../state/prefs';
+import './tokens.css';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: { default: 'Runloop Studio', template: '%s · Runloop Studio' },
+ description: 'Watch real agent work, inspect the trace, replay what happened.',
+};
+
+export const viewport: Viewport = {
+ colorScheme: 'dark light',
+ themeColor: [
+ { media: '(prefers-color-scheme: dark)', color: '#101114' },
+ { media: '(prefers-color-scheme: light)', color: '#f4f5f7' },
+ ],
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ // Geist and Geist Mono are self-hosted by next/font (no network font fetches).
+ // data-theme is set by the inline script before first paint, so it differs from the server HTML.
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/studio/src/app/not-found.tsx b/apps/studio/src/app/not-found.tsx
new file mode 100644
index 0000000..002d100
--- /dev/null
+++ b/apps/studio/src/app/not-found.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import { ButtonLink } from '../ui/Button';
+import { EmptyState } from '../ui/States';
+
+export const metadata: Metadata = { title: 'Not found' };
+
+export default function NotFound() {
+ return (
+
+ Go to the overview
+
+ }
+ >
+ Runloop Studio has Overview, Live World, Traces, Integrations and Settings.
+
+ );
+}
diff --git a/apps/studio/src/app/overview/page.tsx b/apps/studio/src/app/overview/page.tsx
new file mode 100644
index 0000000..edd5060
--- /dev/null
+++ b/apps/studio/src/app/overview/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import OverviewPage from '../../components/pages/OverviewPage';
+
+export const metadata: Metadata = { title: 'Overview' };
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/studio/src/app/page.tsx b/apps/studio/src/app/page.tsx
new file mode 100644
index 0000000..739788a
--- /dev/null
+++ b/apps/studio/src/app/page.tsx
@@ -0,0 +1,15 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+import { parseDeepLink, routeForDeepLink } from '../state/deeplink';
+
+/** `/` redirects: deep links (?run=&view=…) to their page, everything else to the Overview. */
+export default function RootRedirect() {
+ const router = useRouter();
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const query = params.toString();
+ router.replace(`${routeForDeepLink(parseDeepLink(params))}${query ? `?${query}` : ''}`);
+ }, [router]);
+ return null;
+}
diff --git a/apps/studio/src/app/settings/page.tsx b/apps/studio/src/app/settings/page.tsx
new file mode 100644
index 0000000..6e004af
--- /dev/null
+++ b/apps/studio/src/app/settings/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import SettingsPage from '../../components/pages/SettingsPage';
+
+export const metadata: Metadata = { title: 'Settings' };
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/studio/src/app/tokens.css b/apps/studio/src/app/tokens.css
new file mode 100644
index 0000000..e0acbd9
--- /dev/null
+++ b/apps/studio/src/app/tokens.css
@@ -0,0 +1,211 @@
+/*
+ * Runloop Signature design tokens (docs/design/design-system.md).
+ * This file is the only colour source for the Studio interface. Components
+ * use the semantic aliases (--rl-canvas, --rl-text, --rl-accent-…); the
+ * named spec tokens (--rl-obsidian, --rl-lime, …) are redefined per theme.
+ *
+ * Theme: is set before first paint by the
+ * inline script in layout.tsx. Without JS, prefers-color-scheme decides.
+ * Contrast (WCAG AA) is checked for every text/surface pair in both themes
+ * (docs/design/design-system.md). Identity hues --rl-id-1…8 never signal state.
+ */
+
+:root {
+ color-scheme: dark;
+
+ /* ── Signature palette (dark, default) ─────────────────────────── */
+ --rl-obsidian: #101114;
+ --rl-graphite: #1b1d22;
+ --rl-slate: #292c33;
+ --rl-slate-hi: #33363e;
+ --rl-line: rgba(232, 235, 240, 0.08);
+ --rl-line-strong: rgba(232, 235, 240, 0.14);
+ --rl-lime: #b8f36a;
+ --rl-ice: #e8ebf0;
+ --rl-muted: #9297a2;
+ /* Tertiary text: AA on canvas and surface only; never use it for text on slate. */
+ --rl-faint: #878c97;
+ --rl-success: #48c78e;
+ --rl-warning: #f5b84b;
+ --rl-failure: #ff6b76;
+
+ /* Agent identity hues (never state; lime is never an identity hue). */
+ --rl-id-1: #5aa9f5;
+ --rl-id-2: #e0946a;
+ --rl-id-3: #43c6d9;
+ --rl-id-4: #a08cf9;
+ --rl-id-5: #e67ccb;
+ --rl-id-6: #8fa4bf;
+ --rl-id-7: #c38bd9;
+ --rl-id-8: #cdb592;
+ --rl-id-neutral: #9297a2;
+
+ /* ── Semantic aliases (what components use) ────────────────────── */
+ --rl-canvas: var(--rl-obsidian);
+ --rl-surface: var(--rl-graphite);
+ --rl-surface-2: var(--rl-slate);
+ --rl-surface-3: var(--rl-slate-hi);
+ --rl-text: var(--rl-ice);
+ --rl-text-muted: var(--rl-muted);
+ --rl-text-faint: var(--rl-faint);
+ /* Lime as text/icon on surfaces, as a fill, and the text on that fill. */
+ --rl-accent-text: var(--rl-lime);
+ --rl-accent-fill: var(--rl-lime);
+ --rl-accent-fill-hover: #c8f88a;
+ --rl-on-accent: #101114;
+ --rl-accent-soft: rgba(184, 243, 106, 0.12);
+ --rl-info: var(--rl-ice);
+ --rl-scrim: rgba(6, 7, 9, 0.62);
+ /* Glass: reserved for floating controls over the 3D world. */
+ --rl-glass: rgba(27, 29, 34, 0.74);
+ --rl-glass-line: rgba(232, 235, 240, 0.1);
+ --rl-world-bg: #0c0d10;
+ --rl-shadow-pop: 0 12px 32px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.3);
+ --rl-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.25);
+ --rl-focus: var(--rl-lime);
+ --rl-focus-ring: 0 0 0 2px var(--rl-canvas), 0 0 0 4px var(--rl-focus);
+ --rl-selection: rgba(184, 243, 106, 0.24);
+
+ /* ── Type ──────────────────────────────────────────────────────── */
+ --rl-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
+ --rl-font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
+ /* The smallest size the spec allows (UI small and mono: 12 px and up). */
+ --rl-text-2xs: 12px;
+ --rl-text-xs: 12px;
+ --rl-text-sm: 13px;
+ --rl-text-md: 14px;
+ --rl-text-lg: 16px;
+ --rl-text-h3: 20px;
+ --rl-text-h2: 24px;
+ --rl-text-h1: 28px;
+ --rl-text-display: 44px;
+ --rl-tracking-tight: -0.02em;
+
+ /* ── Shape and spacing ─────────────────────────────────────────── */
+ --rl-radius-sm: 6px;
+ --rl-radius-control: 8px;
+ --rl-radius-card: 12px;
+ --rl-radius-hero: 18px;
+ --rl-radius-pill: 999px;
+ --rl-space-1: 4px;
+ --rl-space-2: 8px;
+ --rl-space-3: 12px;
+ --rl-space-4: 16px;
+ --rl-space-5: 20px;
+ --rl-space-6: 24px;
+ --rl-space-8: 32px;
+ --rl-control-h: 32px;
+ --rl-control-h-sm: 28px;
+
+ /* ── Motion ────────────────────────────────────────────────────── */
+ --rl-dur-instant: 120ms;
+ --rl-dur-standard: 200ms;
+ --rl-dur-emphasis: 320ms;
+ --rl-dur-camera: 550ms;
+ --rl-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
+
+ /* ── Layers ────────────────────────────────────────────────────── */
+ --rl-z-overlay: 40;
+ --rl-z-dialog: 50;
+ --rl-z-toast: 60;
+}
+
+/* Light theme: same roles, re-derived for a light canvas (AA checked). */
+:root[data-theme='light'] {
+ color-scheme: light;
+ --rl-obsidian: #f4f5f7;
+ --rl-graphite: #ffffff;
+ --rl-slate: #ebedf1;
+ --rl-slate-hi: #e1e4ea;
+ --rl-line: rgba(16, 17, 20, 0.09);
+ --rl-line-strong: rgba(16, 17, 20, 0.16);
+ --rl-lime: #48700f;
+ --rl-ice: #16181d;
+ --rl-muted: #585e69;
+ --rl-faint: #676d77;
+ --rl-success: #1a7a4e;
+ --rl-warning: #8f5c00;
+ --rl-failure: #c42b3a;
+
+ --rl-id-1: #1f6fcf;
+ --rl-id-2: #b3582a;
+ --rl-id-3: #0b7f91;
+ --rl-id-4: #6a52e0;
+ --rl-id-5: #b03c93;
+ --rl-id-6: #4f6582;
+ --rl-id-7: #8a4ca6;
+ --rl-id-8: #8a6b2e;
+ --rl-id-neutral: #6b717c;
+
+ /* The bright lime survives only as a fill behind dark text. */
+ --rl-accent-fill: #b8f36a;
+ --rl-accent-fill-hover: #a9e957;
+ --rl-on-accent: #101114;
+ --rl-accent-soft: rgba(72, 112, 15, 0.1);
+ --rl-scrim: rgba(22, 24, 29, 0.36);
+ --rl-glass: rgba(255, 255, 255, 0.8);
+ --rl-glass-line: rgba(16, 17, 20, 0.1);
+ --rl-world-bg: #e9ebef;
+ --rl-shadow-pop: 0 12px 32px rgba(22, 24, 29, 0.14), 0 2px 6px rgba(22, 24, 29, 0.08);
+ --rl-shadow-card: 0 1px 2px rgba(22, 24, 29, 0.06);
+ --rl-selection: rgba(184, 243, 106, 0.5);
+}
+
+/* No JS (or before the script ran): follow the system preference. */
+@media (prefers-color-scheme: light) {
+ :root:not([data-theme]) {
+ color-scheme: light;
+ --rl-obsidian: #f4f5f7;
+ --rl-graphite: #ffffff;
+ --rl-slate: #ebedf1;
+ --rl-slate-hi: #e1e4ea;
+ --rl-line: rgba(16, 17, 20, 0.09);
+ --rl-line-strong: rgba(16, 17, 20, 0.16);
+ --rl-lime: #48700f;
+ --rl-ice: #16181d;
+ --rl-muted: #585e69;
+ --rl-faint: #676d77;
+ --rl-success: #1a7a4e;
+ --rl-warning: #8f5c00;
+ --rl-failure: #c42b3a;
+
+ --rl-id-1: #1f6fcf;
+ --rl-id-2: #b3582a;
+ --rl-id-3: #0b7f91;
+ --rl-id-4: #6a52e0;
+ --rl-id-5: #b03c93;
+ --rl-id-6: #4f6582;
+ --rl-id-7: #8a4ca6;
+ --rl-id-8: #8a6b2e;
+ --rl-id-neutral: #6b717c;
+
+ /* The bright lime survives only as a fill behind dark text. */
+ --rl-accent-fill: #b8f36a;
+ --rl-accent-fill-hover: #a9e957;
+ --rl-on-accent: #101114;
+ --rl-accent-soft: rgba(72, 112, 15, 0.1);
+ --rl-scrim: rgba(22, 24, 29, 0.36);
+ --rl-glass: rgba(255, 255, 255, 0.8);
+ --rl-glass-line: rgba(16, 17, 20, 0.1);
+ --rl-world-bg: #e9ebef;
+ --rl-shadow-pop: 0 12px 32px rgba(22, 24, 29, 0.14), 0 2px 6px rgba(22, 24, 29, 0.08);
+ --rl-shadow-card: 0 1px 2px rgba(22, 24, 29, 0.06);
+ --rl-selection: rgba(184, 243, 106, 0.5);
+ }
+}
+
+/* Reduced motion: system preference unless the viewer forced full motion, or forced by the viewer. */
+@media (prefers-reduced-motion: reduce) {
+ :root:not([data-motion='full']) {
+ --rl-dur-instant: 0ms;
+ --rl-dur-standard: 0ms;
+ --rl-dur-emphasis: 0ms;
+ --rl-dur-camera: 0ms;
+ }
+}
+:root[data-motion='reduce'] {
+ --rl-dur-instant: 0ms;
+ --rl-dur-standard: 0ms;
+ --rl-dur-emphasis: 0ms;
+ --rl-dur-camera: 0ms;
+}
diff --git a/apps/studio/src/app/traces/page.tsx b/apps/studio/src/app/traces/page.tsx
new file mode 100644
index 0000000..a44d108
--- /dev/null
+++ b/apps/studio/src/app/traces/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import TracesPage from '../../components/pages/TracesPage';
+
+export const metadata: Metadata = { title: 'Traces' };
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/studio/src/app/world/page.tsx b/apps/studio/src/app/world/page.tsx
new file mode 100644
index 0000000..a760fc6
--- /dev/null
+++ b/apps/studio/src/app/world/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import WorldPage from '../../components/pages/WorldPage';
+
+export const metadata: Metadata = { title: 'Live World' };
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/studio/src/components/AgentFlow.module.css b/apps/studio/src/components/AgentFlow.module.css
new file mode 100644
index 0000000..1799041
--- /dev/null
+++ b/apps/studio/src/components/AgentFlow.module.css
@@ -0,0 +1,45 @@
+.flow {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.arrow {
+ display: grid;
+ place-items: center;
+ color: var(--rl-text-faint);
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 24px;
+ padding: 0 9px 0 7px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ white-space: nowrap;
+}
+.label {
+ color: var(--rl-text);
+ font-weight: 500;
+}
+.status {
+ color: inherit;
+}
+.chip[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.chip[data-tone='err'] {
+ color: var(--rl-failure);
+}
+.chip[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.chip[data-tone='live'] {
+ color: var(--rl-accent-text);
+ background: var(--rl-accent-soft);
+}
diff --git a/apps/studio/src/components/AgentFlow.tsx b/apps/studio/src/components/AgentFlow.tsx
new file mode 100644
index 0000000..f132196
--- /dev/null
+++ b/apps/studio/src/components/AgentFlow.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { Fragment, useMemo } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { flowChips } from '../state/view';
+import { GlyphIcon, Icon } from '../ui/Icon';
+import styles from './AgentFlow.module.css';
+
+/** One chip per task kind at the playhead (core selectAgentFlow): the mission's shape at a glance. */
+export default function AgentFlow() {
+ const store = useStudioStore();
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const chips = useMemo(() => flowChips(store.getState().stateAt(seq).exec), [store, seq, lastSequence]);
+ if (chips.length === 0) return null;
+
+ return (
+
+ {chips.map((c, i) => (
+
+ {i > 0 && (
+
+
+
+ )}
+
+
+ {c.label}
+ {c.view.text}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/studio/src/components/AgentsTable.module.css b/apps/studio/src/components/AgentsTable.module.css
new file mode 100644
index 0000000..236b39b
--- /dev/null
+++ b/apps/studio/src/components/AgentsTable.module.css
@@ -0,0 +1,115 @@
+.wrap {
+ position: absolute;
+ inset: 0;
+ overflow: auto;
+ padding: 56px var(--rl-space-4) var(--rl-space-4);
+}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--rl-text-xs);
+}
+.table th,
+.table td {
+ padding: 8px var(--rl-space-3);
+ text-align: left;
+ vertical-align: middle;
+}
+.table thead th {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.table tbody tr {
+ box-shadow: inset 0 1px 0 var(--rl-line);
+}
+.table tbody tr[data-selected] {
+ background: var(--rl-accent-soft);
+}
+.table tbody tr[data-selected] th {
+ box-shadow: inset 2px 0 0 var(--rl-accent-text);
+}
+.agent {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ text-align: left;
+ border-radius: 4px;
+}
+.name {
+ color: var(--rl-text);
+ font-weight: 600;
+}
+.role {
+ color: var(--rl-text-muted);
+ font-weight: 400;
+}
+.task {
+ max-width: 260px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.event {
+ max-width: 320px;
+}
+.eventLink {
+ display: inline-flex;
+ gap: var(--rl-space-2);
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--rl-text);
+ text-align: left;
+ border-radius: 4px;
+}
+.eventLink:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.clock {
+ color: var(--rl-text-faint);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.muted {
+ color: var(--rl-text-muted);
+}
+@media (max-width: 760px) {
+ .wrap {
+ position: static;
+ padding: var(--rl-space-2) 0;
+ }
+ .table thead {
+ display: none;
+ }
+ .table tr {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ padding: var(--rl-space-2) 0;
+ }
+ .table th,
+ .table td {
+ padding: 2px var(--rl-space-3);
+ }
+ .table td:nth-child(n + 3) {
+ grid-column: 1 / -1;
+ }
+ .table td[data-empty] {
+ display: none;
+ }
+}
+
+.tokens {
+ white-space: nowrap;
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.none {
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-sans);
+ font-style: italic;
+}
diff --git a/apps/studio/src/components/AgentsTable.tsx b/apps/studio/src/components/AgentsTable.tsx
new file mode 100644
index 0000000..f3fe4ad
--- /dev/null
+++ b/apps/studio/src/components/AgentsTable.tsx
@@ -0,0 +1,102 @@
+'use client';
+import { formatClock } from '@runloop-world/core';
+import { useMemo } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { describeEvent, formatInt, usageSourceText, usageText, usageTotal } from '../state/labels';
+import { latestEventOf } from '../state/selection';
+import { agentLook, KIND_LABEL, runHasEnded, taskStatusView } from '../state/view';
+import { AgentDot, StatusBadge } from '../ui/Chip';
+import { runtimeStatusView } from '../views/graph/model';
+import { EmptyState } from '../ui/States';
+import CharacterStatus from './CharacterStatus';
+import styles from './AgentsTable.module.css';
+
+/**
+ * The 2D route to everything the world shows: every agent, what it is doing at
+ * the playhead, where, and its latest event. Selecting a row selects the agent
+ * everywhere. (The agent graph view will join this slot in the next phase.)
+ */
+export default function AgentsTable() {
+ const store = useStudioStore();
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const world = useStudio((s) => s.world);
+ const selected = useStudio((s) => s.selectedAgentId);
+ const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]);
+ const latest = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+ const { exec } = state;
+ const s = store.getState();
+ const ended = runHasEnded(exec);
+
+ if (exec.agentOrder.length === 0) {
+ return (
+
+ Agents appear as soon as they register in the run.
+
+ );
+ }
+
+ return (
+
+
+ Agents at the playhead (event #{seq})
+
+
+ Agent
+ State
+ Task
+ Where
+ Tokens
+ Latest event
+
+
+
+ {exec.agentOrder.map((id) => {
+ const a = exec.agents[id]!;
+ const look = agentLook(exec, world, id);
+ const task = a.currentTaskId ? exec.tasks[a.currentTaskId] : undefined;
+ const character = state.world.characters[id];
+ const station = character?.stationId && world ? world.stationsById[character.stationId] : undefined;
+ const last = latestEventOf(s.events(), latest, id, seq);
+ const lastEvent = last !== null ? s.events()[last - 1] : undefined;
+ return (
+
+
+ s.selectAgent(selected === id ? null : id)} aria-pressed={selected === id}>
+
+ {a.name}
+ {a.role}
+
+
+ {a.embodied ? : }
+
+ {task ? `${KIND_LABEL[task.kind] ?? task.kind} · ${task.title}${ended ? ` · ${taskStatusView(task.status, true).text}` : ''}` : — }
+
+ {station ? station.label : {a.embodied ? 'home' : 'not in the world'} }
+
+ {a.usage ? (
+ {usageText(a.usage)}
+ ) : a.role === 'user' ? (
+ —
+ ) : (
+ not reported
+ )}
+
+
+ {lastEvent ? (
+ s.selectEvent(lastEvent.sequence)}>
+ {formatClock(s.timeOfSeq(lastEvent.sequence))}
+ {describeEvent(lastEvent, latest).label}
+
+ ) : (
+ —
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/AppShell.module.css b/apps/studio/src/components/AppShell.module.css
new file mode 100644
index 0000000..2a9db09
--- /dev/null
+++ b/apps/studio/src/components/AppShell.module.css
@@ -0,0 +1,106 @@
+.shell {
+ --sidebar-w: 232px;
+ display: grid;
+ grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
+ height: 100vh;
+ height: 100dvh;
+ background: var(--rl-canvas);
+}
+.shell[data-rail] {
+ --sidebar-w: 64px;
+}
+.shell[data-mobile] {
+ grid-template-columns: minmax(0, 1fr);
+ height: auto;
+ min-height: 100dvh;
+}
+
+.skip {
+ position: absolute;
+ left: var(--rl-space-3);
+ top: -60px;
+ z-index: var(--rl-z-toast);
+ padding: var(--rl-space-2) var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-accent-fill);
+ color: var(--rl-on-accent);
+ font-weight: 600;
+}
+.skip:focus {
+ top: var(--rl-space-3);
+}
+
+.sidebar {
+ min-height: 0;
+ overflow: hidden;
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ height: 52px;
+ flex: none;
+ padding: 0 var(--rl-space-5) 0 var(--rl-space-4);
+}
+.shell[data-mobile] .topbar {
+ position: sticky;
+ top: 0;
+ z-index: var(--rl-z-overlay);
+ padding: 0 var(--rl-space-3);
+ background: var(--rl-canvas);
+}
+.topActions {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ margin-left: auto;
+ flex: none;
+}
+.search {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ height: 30px;
+ padding: 0 6px 0 10px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ transition: background-color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.search:hover {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+.shell[data-mobile] .search {
+ width: 32px;
+ padding: 0;
+ justify-content: center;
+}
+.searchText {
+ min-width: 96px;
+ text-align: left;
+}
+
+.content {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ outline: none;
+}
+
+.drawer {
+ width: min(300px, 86vw);
+ height: 100%;
+ max-height: none;
+ border-radius: 0 var(--rl-radius-card) var(--rl-radius-card) 0;
+}
diff --git a/apps/studio/src/components/AppShell.tsx b/apps/studio/src/components/AppShell.tsx
new file mode 100644
index 0000000..d3fd1d7
--- /dev/null
+++ b/apps/studio/src/components/AppShell.tsx
@@ -0,0 +1,162 @@
+'use client';
+import { usePathname } from 'next/navigation';
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { connectRun } from '../state/connect';
+import { connectionState, connectionView } from '../state/connection';
+import { StudioProvider, useStudio } from '../state/context';
+import { AREA_LABEL, areaOf, isRunPage, runNumber } from '../state/format';
+import { useDeepLinkOnce, useKeyboardShortcuts, useOnboardingTracker, usePlaybackDriver, usePrefs, useRuntimePolling, useUrlSync } from '../state/hooks';
+import { createStudioStore, type StudioStore } from '../state/store';
+import { Breadcrumbs, type Crumb } from '../ui/Breadcrumbs';
+import { Button } from '../ui/Button';
+import { CommandPalette } from '../ui/CommandPalette';
+import { ConnectionStatus } from '../ui/ConnectionStatus';
+import { Dialog } from '../ui/Dialog';
+import { Kbd, modKey } from '../ui/Kbd';
+import { ToastRegion } from '../ui/Toast';
+import { MOBILE_QUERY, NARROW_QUERY, useMediaQuery } from '../ui/useMediaQuery';
+import styles from './AppShell.module.css';
+import ArtifactViewer from './ArtifactViewer';
+import StudioSidebar from './StudioSidebar';
+import { useStudioCommands } from './useStudioCommands';
+
+/**
+ * Root client component: one studio store for the whole app (so a live run
+ * keeps streaming across pages), the runtime connection, and the chrome.
+ */
+export default function AppShell({ children }: { children: ReactNode }) {
+ const [store] = useState(() => createStudioStore());
+ return (
+
+ {children}
+
+ );
+}
+
+function Shell({ store, children }: { store: StudioStore; children: ReactNode }) {
+ const pathname = usePathname() ?? '/';
+ const runId = useStudio((s) => s.runId);
+ const connectNonce = useStudio((s) => s.connectNonce);
+ const apiKey = useStudio((s) => s.apiBaseOverride) ?? '';
+ const retry = useReconnectNonce(store);
+ const mobile = useMediaQuery(MOBILE_QUERY);
+ const narrow = useMediaQuery(NARROW_QUERY);
+ const [paletteOpen, setPaletteOpen] = useState(false);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ // Order matters: preferences (runtime URL) before the deep link and polling.
+ usePrefs(store);
+ useDeepLinkOnce(store);
+ useRuntimePolling(store, apiKey);
+ usePlaybackDriver(store);
+ useKeyboardShortcuts(store);
+ useUrlSync(store, pathname);
+ useOnboardingTracker(store, pathname);
+ useEffect(() => (runId ? connectRun(store, runId) : undefined), [store, runId, retry, connectNonce]);
+ useEffect(() => setDrawerOpen(false), [pathname]);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ setPaletteOpen((o) => !o);
+ return;
+ }
+ const target = e.target instanceof Element ? e.target : null;
+ if (e.key === '/' && !e.metaKey && !e.ctrlKey && !target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) {
+ const search = document.getElementById('trace-search');
+ if (search) {
+ e.preventDefault();
+ search.focus();
+ }
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, []);
+
+ const commands = useStudioCommands(pathname);
+
+ return (
+
+
+ Skip to content
+
+ {!mobile && (
+
+ )}
+
+ setPaletteOpen(true)} onOpenMenu={() => setDrawerOpen(true)} />
+
+ {children}
+
+
+ {mobile && (
+
setDrawerOpen(false)} labelledBy="drawer-title" placement="left" className={styles.drawer}>
+
+ Navigation
+
+ setDrawerOpen(false)} />
+
+ )}
+
setPaletteOpen(false)} commands={commands} />
+
+
+
+ );
+}
+
+function TopBar({ pathname, mobile, onOpenPalette, onOpenMenu }: { pathname: string; mobile: boolean; onOpenPalette: () => void; onOpenMenu: () => void }) {
+ const area = areaOf(pathname);
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const run = useStudio((s) => s.run);
+ const conn = connectionView(useStudio((s) => connectionState(s)));
+ const [mod, setMod] = useState('Ctrl');
+ useEffect(() => setMod(modKey()), []);
+
+ const crumbs: Crumb[] = [{ label: AREA_LABEL[area] ?? 'Studio', href: area }];
+ // Phones show the area only; the run title is in the page header.
+ if (!mobile && isRunPage(pathname) && runId) {
+ const n = runNumber(runs, runId);
+ crumbs.push({ label: `${n !== null ? `Run #${n}` : 'Run'}${run ? ` · ${run.title}` : ''}` });
+ }
+
+ return (
+
+ {mobile && }
+
+
+
+
+
+
+
+ {!mobile && Jump to… }
+ {!mobile && }
+
+
+
+ );
+}
+
+/** Bumps when the runtime comes back online after the open run failed to load or stream. */
+function useReconnectNonce(store: StudioStore): number {
+ const [nonce, setNonce] = useState(0);
+ const prev = useRef(store.getState().runtime.status);
+ useEffect(
+ () =>
+ store.subscribe((s) => {
+ const was = prev.current;
+ prev.current = s.runtime.status;
+ if (was === 'offline' && s.runtime.status === 'online' && s.runId && (s.loadError || s.conn === 'error')) {
+ setNonce((n) => n + 1);
+ }
+ }),
+ [store],
+ );
+ return nonce;
+}
+
diff --git a/apps/studio/src/components/ArtifactViewer.module.css b/apps/studio/src/components/ArtifactViewer.module.css
new file mode 100644
index 0000000..6066280
--- /dev/null
+++ b/apps/studio/src/components/ArtifactViewer.module.css
@@ -0,0 +1,77 @@
+.dialog {
+ width: min(880px, 100%);
+ height: min(760px, calc(100dvh - 32px));
+}
+.header {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--rl-space-3);
+ padding: var(--rl-space-4) var(--rl-space-3) var(--rl-space-3) var(--rl-space-5);
+}
+.icon {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ flex: none;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.heading {
+ flex: 1;
+ min-width: 0;
+}
+.title {
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-md);
+ font-weight: 500;
+ overflow-wrap: anywhere;
+}
+.summary {
+ margin-top: 2px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-2) var(--rl-space-5);
+ padding: 0 var(--rl-space-5) var(--rl-space-3);
+ font-size: var(--rl-text-xs);
+}
+.meta dt {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.meta dd {
+ color: var(--rl-text);
+}
+.wide {
+ min-width: 0;
+}
+.hash {
+ font-size: var(--rl-text-2xs);
+ overflow-wrap: anywhere;
+}
+.content {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin: 0 var(--rl-space-3) var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-canvas);
+ overflow: hidden;
+}
+.pre {
+ flex: 1;
+ margin: 0;
+ padding: var(--rl-space-4);
+ overflow: auto;
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ line-height: 1.65;
+}
diff --git a/apps/studio/src/components/ArtifactViewer.tsx b/apps/studio/src/components/ArtifactViewer.tsx
new file mode 100644
index 0000000..0946b07
--- /dev/null
+++ b/apps/studio/src/components/ArtifactViewer.tsx
@@ -0,0 +1,104 @@
+'use client';
+import { useEffect, useMemo, useState } from 'react';
+import { getArtifactText } from '../lib/api';
+import { useStudio, useStudioStore } from '../state/context';
+import { Button } from '../ui/Button';
+import { Dialog } from '../ui/Dialog';
+import { Icon } from '../ui/Icon';
+import { ErrorState, SkeletonRows } from '../ui/States';
+import styles from './ArtifactViewer.module.css';
+
+type Load = { status: 'loading' } | { status: 'ok'; text: string } | { status: 'error'; message: string };
+
+/** An artifact's metadata (from the trace) and its content (GET /api/runs/:id/artifacts/:aid). */
+export default function ArtifactViewer() {
+ const store = useStudioStore();
+ const artifactId = useStudio((s) => s.viewArtifactId);
+ const runId = useStudio((s) => s.runId);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const artifact = useMemo(
+ () => (artifactId ? store.getState().stateAt(lastSequence).exec.artifacts[artifactId] : undefined),
+ [store, artifactId, lastSequence],
+ );
+ const [load, setLoad] = useState({ status: 'loading' });
+ const [attempt, setAttempt] = useState(0);
+
+ useEffect(() => {
+ if (!artifactId || !runId) return;
+ let cancelled = false;
+ setLoad({ status: 'loading' });
+ getArtifactText(runId, artifactId).then(
+ (text) => !cancelled && setLoad({ status: 'ok', text }),
+ (err: unknown) => !cancelled && setLoad({ status: 'error', message: err instanceof Error ? err.message : String(err) }),
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactId, runId, attempt]);
+
+ const close = () => store.getState().openArtifact(null);
+
+ return (
+
+
+
+
+
+
+
+ {artifact?.name ?? artifactId}
+
+ {artifact &&
{artifact.summary}
}
+
+
+
+ {artifact && (
+
+
+
Kind
+ {artifact.kind}
+
+
+
Type
+ {artifact.mediaType}
+
+
+
Size
+ {artifact.bytes.toLocaleString()} bytes
+
+ {artifact.path && (
+
+
Path
+
+ {artifact.path}
+
+
+ )}
+
+
SHA-256
+
+ {artifact.sha256}
+
+
+ {artifact.producedByCallId && (
+
+
Produced by
+
+ {artifact.producedByCallId}
+
+
+ )}
+
+ )}
+
+ {load.status === 'loading' &&
}
+ {load.status === 'error' && (
+
setAttempt((n) => n + 1)}>
+ {load.message}
+
+ )}
+ {load.status === 'ok' &&
{load.text} }
+
+
+ );
+}
diff --git a/apps/studio/src/components/CharacterRoster.tsx b/apps/studio/src/components/CharacterRoster.tsx
new file mode 100644
index 0000000..625a8fe
--- /dev/null
+++ b/apps/studio/src/components/CharacterRoster.tsx
@@ -0,0 +1,41 @@
+'use client';
+import { selectEmbodiedAgents } from '@runloop-world/core';
+import { useMemo } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { agentLook } from '../state/view';
+import { AgentDot } from '../ui/Chip';
+import CharacterStatus from './CharacterStatus';
+import styles from './WorldStage.module.css';
+
+/** Keyboard-reachable list of characters over the canvas (mirrors clicking them in 3D). */
+export default function CharacterRoster() {
+ const store = useStudioStore();
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const world = useStudio((s) => s.world);
+ const selected = useStudio((s) => s.selectedAgentId);
+ const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]);
+ const agents = selectEmbodiedAgents(state.exec);
+ if (agents.length === 0) return null;
+
+ return (
+
+ {agents.map((a) => {
+ const isSelected = selected === a.id;
+ return (
+
store.getState().selectAgent(isSelected ? null : a.id)}
+ >
+
+ {a.name}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/studio/src/components/CharacterStatus.tsx b/apps/studio/src/components/CharacterStatus.tsx
new file mode 100644
index 0000000..ed86b72
--- /dev/null
+++ b/apps/studio/src/components/CharacterStatus.tsx
@@ -0,0 +1,33 @@
+'use client';
+import { presentationAt } from '@runloop-world/world-engine';
+import { useSyncExternalStore } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { useTicker } from '../state/hooks';
+import { characterStatusView } from '../state/view';
+import { StatusBadge } from '../ui/Chip';
+
+/**
+ * A character's state exactly as its 3D badge shows it at the playhead
+ * (Layer-2 state → presentationAt at the frame's tick), so DOM panels and
+ * the scene never disagree. Re-renders while the playhead moves.
+ */
+export default function CharacterStatus({ agentId }: { agentId: string }) {
+ const store = useStudioStore();
+ const world = useStudio((s) => s.world);
+ const frame = store.getState().getFrame();
+ // Live: the playhead follows wall time. Replay: re-render on scrubs, at tick resolution.
+ useTicker(frame.advancing, 200);
+ useSyncExternalStore(
+ store.subscribe,
+ () => {
+ const s = store.getState();
+ return s.cursor.mode === 'replay' ? Math.round(s.cursor.tMs / 100) : s.playheadSeq;
+ },
+ () => 0,
+ );
+
+ const c = frame.state.world.characters[agentId];
+ if (!c || !world) return null;
+ const pose = presentationAt(c, frame.tick, world, { paused: frame.state.world.paused });
+ return ;
+}
diff --git a/apps/studio/src/components/FilterBar.module.css b/apps/studio/src/components/FilterBar.module.css
new file mode 100644
index 0000000..16c5dae
--- /dev/null
+++ b/apps/studio/src/components/FilterBar.module.css
@@ -0,0 +1,161 @@
+.bar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+}
+.row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--rl-space-2);
+ min-width: 0;
+}
+.search {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ flex: 1 1 220px;
+ min-width: 160px;
+ max-width: 420px;
+ height: 30px;
+ padding: 0 6px 0 10px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.search:focus-within {
+ box-shadow: var(--rl-focus-ring);
+}
+.input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ border: 0;
+ background: transparent;
+ color: var(--rl-text);
+ font-size: var(--rl-text-sm);
+}
+.input:focus-visible {
+ outline: none;
+ box-shadow: none;
+}
+/* On slate, so muted (faint only reaches AA on canvas and surface). */
+.input::placeholder {
+ color: var(--rl-text-muted);
+}
+.input::-webkit-search-cancel-button {
+ filter: grayscale(1);
+}
+.statuses {
+ display: flex;
+ gap: 2px;
+ padding: 2px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 6px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.status:hover {
+ color: var(--rl-text);
+}
+.status[aria-pressed='true'] {
+ background: var(--rl-surface);
+ color: var(--rl-text);
+ box-shadow: var(--rl-shadow-card);
+}
+.status[aria-pressed='true'][data-status='ok'] svg {
+ color: var(--rl-success);
+}
+.status[aria-pressed='true'][data-status='error'] svg {
+ color: var(--rl-failure);
+}
+.status[aria-pressed='true'][data-status='running'] svg {
+ color: var(--rl-accent-text);
+}
+.status[aria-pressed='true'][data-status='inferred'] svg {
+ color: var(--rl-warning);
+}
+.facets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-1);
+}
+.count {
+ margin-left: auto;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ white-space: nowrap;
+}
+.fieldset {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ display: flex;
+ flex-direction: column;
+}
+.legend {
+ padding: 4px 8px 6px;
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.option {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ min-height: 30px;
+ padding: 0 8px;
+ border-radius: var(--rl-radius-sm);
+ cursor: pointer;
+}
+.option:hover {
+ background: var(--rl-surface-2);
+}
+.option input {
+ accent-color: var(--rl-accent-text);
+ margin: 0;
+}
+.optionText {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--rl-text-xs);
+}
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-1);
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 24px;
+ padding: 0 6px 0 9px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-accent-soft);
+ color: var(--rl-accent-text);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.chip:hover {
+ background: color-mix(in srgb, var(--rl-accent-text) 18%, transparent);
+}
+.bar[data-compact] .statuses .status {
+ padding: 0 6px;
+}
diff --git a/apps/studio/src/components/FilterBar.tsx b/apps/studio/src/components/FilterBar.tsx
new file mode 100644
index 0000000..70470f4
--- /dev/null
+++ b/apps/studio/src/components/FilterBar.tsx
@@ -0,0 +1,165 @@
+'use client';
+import { useId, type ReactNode } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { activeFilterCount, EMPTY_FILTER, toggleFacet, type Facets, type StatusFilter, type TraceFilter } from '../state/filters';
+import { Button } from '../ui/Button';
+import { AgentDot } from '../ui/Chip';
+import { Icon, type IconName } from '../ui/Icon';
+import { Kbd } from '../ui/Kbd';
+import { Popover } from '../ui/Popover';
+import styles from './FilterBar.module.css';
+
+const STATUS: { id: StatusFilter; label: string; icon: IconName }[] = [
+ { id: 'ok', label: 'OK', icon: 'check' },
+ { id: 'error', label: 'Error', icon: 'x' },
+ { id: 'running', label: 'Running', icon: 'pulse' },
+ { id: 'inferred', label: 'Inferred', icon: 'inferred' },
+];
+
+type ListKey = 'agents' | 'tools' | 'categories' | 'types';
+
+export interface AgentOption {
+ id: string;
+ name: string;
+ color: string;
+}
+
+/**
+ * Search and facet filters for the timeline and logs. The filter lives in the
+ * store (and the URL), so both lists and every page share it.
+ */
+export default function FilterBar({
+ facets,
+ agents,
+ shown,
+ total,
+ noun,
+ compact,
+}: {
+ facets: Facets;
+ agents: readonly AgentOption[];
+ shown: number;
+ total: number;
+ noun: string;
+ compact?: boolean;
+}) {
+ const store = useStudioStore();
+ const filter = useStudio((s) => s.filter);
+ const set = (f: TraceFilter) => store.getState().setFilter(f);
+ const id = useId();
+ const active = activeFilterCount(filter);
+ const nameOf = (agentId: string) => agents.find((a) => a.id === agentId)?.name ?? agentId;
+
+ const facetMenu = (key: ListKey, label: string, values: readonly string[], render?: (v: string) => ReactNode) => {
+ const selected = filter[key];
+ if (values.length === 0 && selected.length === 0) return null;
+ const all = [...new Set([...values, ...selected])];
+ return (
+ 0}>
+
+ {label}
+ {all.map((v) => (
+
+ set(toggleFacet(filter, key, v))} />
+ {render ? render(v) : {v} }
+
+ ))}
+
+
+ );
+ };
+
+ const agentRender = (v: string) => {
+ const a = agents.find((x) => x.id === v);
+ return (
+
+ {a?.name ?? (v === 'system' ? 'System' : v)}
+
+ );
+ };
+ const monoRender = (v: string) => {v};
+
+ const chips: { key: ListKey; value: string; label: string }[] = [
+ ...filter.agents.map((v) => ({ key: 'agents' as const, value: v, label: `Agent: ${nameOf(v)}` })),
+ ...filter.tools.map((v) => ({ key: 'tools' as const, value: v, label: `Tool: ${v}` })),
+ ...filter.categories.map((v) => ({ key: 'categories' as const, value: v, label: `Category: ${v}` })),
+ ...filter.types.map((v) => ({ key: 'types' as const, value: v, label: `Type: ${v}` })),
+ ];
+
+ return (
+
+
+
+
+
+ Search {noun}
+
+ set({ ...filter, text: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape' && filter.text) {
+ e.preventDefault();
+ set({ ...filter, text: '' });
+ }
+ }}
+ aria-describedby={`${id}-count`}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ {!filter.text && }
+
+
+ {STATUS.map((st) => (
+ set(toggleFacet(filter, 'statuses', st.id))}
+ >
+
+ {st.label}
+
+ ))}
+
+
+ {facetMenu('agents', 'Agent', facets.agents, agentRender)}
+ {facetMenu('tools', 'Tool', facets.tools, monoRender)}
+ {facetMenu('categories', 'Category', facets.categories)}
+ {facetMenu('types', 'Event type', facets.types, monoRender)}
+
+
+ {active > 0 ? `${shown} of ${total} ${noun}` : `${total} ${noun}`}
+
+ {active > 0 && chips.length === 0 && (
+
set(EMPTY_FILTER)}>
+ Clear all
+
+ )}
+
+ {chips.length > 0 && (
+
+
+ {chips.map((c) => (
+
+ set(toggleFacet(filter, c.key, c.value))} aria-label={`Remove filter ${c.label}`}>
+ {c.label}
+
+
+
+ ))}
+
+
set(EMPTY_FILTER)}>
+ Clear all
+
+
+ )}
+
+ );
+}
diff --git a/apps/studio/src/components/InfoPanels.module.css b/apps/studio/src/components/InfoPanels.module.css
new file mode 100644
index 0000000..c646b64
--- /dev/null
+++ b/apps/studio/src/components/InfoPanels.module.css
@@ -0,0 +1,112 @@
+.scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--rl-space-3) var(--rl-space-4) var(--rl-space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-4);
+}
+.note {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.note strong {
+ color: var(--rl-text);
+}
+.banner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-3) var(--rl-space-4);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+}
+.h2 {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.dl {
+ display: flex;
+ flex-direction: column;
+}
+.row {
+ display: grid;
+ grid-template-columns: minmax(120px, 30%) minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ padding: 6px 0;
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+ font-size: var(--rl-text-xs);
+}
+.row dt {
+ color: var(--rl-text-muted);
+}
+.row dd {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.none {
+ color: var(--rl-text-faint);
+ font-style: italic;
+}
+.anomalies {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.anomaly {
+ display: flex;
+ gap: var(--rl-space-2);
+ width: 100%;
+ padding: 4px var(--rl-space-2);
+ border-radius: var(--rl-radius-sm);
+ text-align: left;
+ font-size: var(--rl-text-xs);
+}
+.anomaly:hover {
+ background: var(--rl-surface-2);
+}
+.anomaly span {
+ color: var(--rl-text-muted);
+}
+.switch {
+ display: inline-flex;
+ align-self: flex-start;
+ gap: 2px;
+ padding: 2px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.switch button {
+ height: 26px;
+ padding: 0 10px;
+ border-radius: 6px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.switch button[aria-pressed='true'] {
+ background: var(--rl-surface);
+ color: var(--rl-text);
+ box-shadow: var(--rl-shadow-card);
+}
+.pre {
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-canvas);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ line-height: 1.6;
+ overflow: auto;
+}
diff --git a/apps/studio/src/components/Inspector.module.css b/apps/studio/src/components/Inspector.module.css
new file mode 100644
index 0000000..0239291
--- /dev/null
+++ b/apps/studio/src/components/Inspector.module.css
@@ -0,0 +1,395 @@
+.inspector {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+ animation: slide-in var(--rl-dur-emphasis) var(--rl-ease-out);
+}
+@keyframes slide-in {
+ from {
+ opacity: 0;
+ transform: translateX(8px);
+ }
+}
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-1) var(--rl-space-2) 0 var(--rl-space-2);
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+}
+.body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--rl-space-4);
+}
+.stack {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-3);
+}
+.eventHead,
+.agentHead {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--rl-space-3);
+}
+.eventIcon {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.eventIcon[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.eventIcon[data-tone='err'] {
+ color: var(--rl-failure);
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+}
+.eventIcon[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.avatar {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 32px;
+ height: 32px;
+ border-radius: 10px;
+ color: var(--rl-canvas);
+ font-weight: 700;
+}
+.eventTitleWrap {
+ min-width: 0;
+}
+.eventTitle {
+ font-size: var(--rl-text-lg);
+ font-weight: 600;
+ line-height: 1.35;
+ letter-spacing: -0.005em;
+ overflow-wrap: anywhere;
+}
+.eventSub {
+ margin-top: 2px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.eventSub code {
+ color: var(--rl-text-muted);
+}
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-1);
+}
+.detail {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+ overflow-wrap: anywhere;
+}
+.provenance {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-2);
+}
+.actions:empty {
+ display: none;
+}
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ padding-top: var(--rl-space-3);
+ box-shadow: inset 0 1px 0 var(--rl-line);
+}
+.h3 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 24px;
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.asideNote {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 400;
+ letter-spacing: 0;
+ text-transform: none;
+}
+.facts {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.fact {
+ display: grid;
+ grid-template-columns: minmax(88px, 34%) minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ font-size: var(--rl-text-xs);
+}
+.fact dt {
+ color: var(--rl-text-muted);
+}
+.fact dd {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.none {
+ color: var(--rl-text-faint);
+ font-style: italic;
+}
+.inline {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.id {
+ font-size: var(--rl-text-2xs);
+ color: var(--rl-text-muted);
+}
+.link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--rl-text);
+ text-decoration: underline;
+ text-decoration-color: var(--rl-line-strong);
+ text-underline-offset: 3px;
+ border-radius: 4px;
+}
+.link:hover {
+ text-decoration-color: currentColor;
+}
+.links {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.eventLink {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ width: 100%;
+ min-height: 30px;
+ padding: 4px var(--rl-space-2);
+ border-radius: var(--rl-radius-sm);
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ text-align: left;
+}
+.eventLink:hover {
+ background: var(--rl-surface-3);
+}
+/* Inside slate link rows: muted, since faint text only reaches AA on canvas and surface. */
+.seq {
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.type {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+}
+.eventLabel {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.chainLabel {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.metrics {
+ display: flex;
+ gap: var(--rl-space-2);
+}
+.metric {
+ flex: 1;
+ padding: var(--rl-space-2) var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.metric strong {
+ display: block;
+ color: var(--rl-text);
+ font-size: var(--rl-text-h3);
+ font-weight: 600;
+ line-height: 1.2;
+}
+.metric[data-tone='ok'] strong {
+ color: var(--rl-success);
+}
+.metric[data-tone='err'] strong {
+ color: var(--rl-failure);
+}
+.failing {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.failing li {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--rl-failure);
+ font-size: var(--rl-text-xs);
+}
+.failing code {
+ color: var(--rl-text);
+ overflow-wrap: anywhere;
+}
+.json {
+ max-height: 360px;
+ overflow: auto;
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-canvas);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ line-height: 1.6;
+}
+.muted {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.warn {
+ color: var(--rl-warning);
+ font-size: var(--rl-text-xs);
+}
+.toolList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.tag {
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: var(--rl-surface-2);
+ font-size: var(--rl-text-2xs);
+}
+.task {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.taskTop {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--rl-space-2);
+}
+.taskTitle {
+ font-weight: 500;
+}
+.progress {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rl-surface-3);
+ overflow: hidden;
+}
+.progress span {
+ display: block;
+ height: 100%;
+ border-radius: 2px;
+ background: var(--rl-accent-text);
+ transition: width var(--rl-dur-emphasis) var(--rl-ease-out);
+}
+.progress[data-indeterminate] span {
+ width: 30% !important;
+ background: var(--rl-text-faint);
+}
+.action {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--rl-space-2);
+ width: 100%;
+ padding: 6px var(--rl-space-2);
+ border-radius: var(--rl-radius-sm);
+ text-align: left;
+}
+.action:hover:not(:disabled) {
+ background: var(--rl-surface-2);
+}
+.action:disabled {
+ cursor: default;
+}
+.actionIcon {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 18px;
+ height: 18px;
+ margin-top: 1px;
+ border-radius: 5px;
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.actionIcon[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.actionIcon[data-tone='err'] {
+ color: var(--rl-failure);
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+}
+.actionIcon[data-tone='live'] {
+ color: var(--rl-accent-text);
+}
+.actionBody {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+.actionTitle {
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+ overflow-wrap: anywhere;
+}
+
+.summary {
+ overflow-wrap: anywhere;
+}
+.provLines {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.provLines > span {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+
+.asideChip {
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 500;
+}
diff --git a/apps/studio/src/components/Inspector.tsx b/apps/studio/src/components/Inspector.tsx
new file mode 100644
index 0000000..bded4dd
--- /dev/null
+++ b/apps/studio/src/components/Inspector.tsx
@@ -0,0 +1,732 @@
+'use client';
+import { formatClock, selectRecentActions, selectTaskProgress, type ExecState, type ToolCallExec } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent } from '@runloop-world/events';
+import { usePathname } from 'next/navigation';
+import { useMemo, useState, type ReactNode } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES, linkTo } from '../state/deeplink';
+import { formatWhen } from '../state/format';
+import { CATEGORY_ICON, categoryLabel, describeEvent, formatInt, formatTokens, isTracedExec, usageParts, usageSourceText, usageText, usageTotal } from '../state/labels';
+import { INFERRED, runProvenance } from '../state/provenance';
+import { isTracedRun } from '../state/runs';
+import { agentOfEvent, artifactIdsOf, callIdOf, eventProvenance, latestEventOf, parentChain, subagentOfCall, type InspectorTab } from '../state/selection';
+import { formatDuration } from '../state/timeline';
+import { agentLook, callStatusView, KIND_LABEL, runHasEnded, runStatusView, taskStatusView } from '../state/view';
+import { Button } from '../ui/Button';
+import { AgentDot, Chip, StatusBadge } from '../ui/Chip';
+import { Icon } from '../ui/Icon';
+import { EmptyState } from '../ui/States';
+import { Tabs } from '../ui/Tabs';
+import { toast } from '../ui/Toast';
+import { runtimeStatusView } from '../views/graph/model';
+import CharacterStatus from './CharacterStatus';
+import styles from './Inspector.module.css';
+import PayloadView from './PayloadView';
+import RunMetrics from './RunMetrics';
+import SourceFacts from './SourceFacts';
+
+const TABS: { id: InspectorTab; label: string }[] = [
+ { id: 'event', label: 'Event' },
+ { id: 'agent', label: 'Agent' },
+ { id: 'run', label: 'Run' },
+];
+
+/**
+ * The inspector: one view of the shared selection. Event = the selected
+ * event; Agent = the selected agent's card; Run = summary, tasks, failures.
+ */
+export default function Inspector({ onClose }: { onClose?: () => void }) {
+ const store = useStudioStore();
+ const tab = useStudio((s) => s.inspectorTab);
+ const runId = useStudio((s) => s.runId);
+ const hasSelection = useStudio((s) => s.selectedSeq !== null || s.selectedAgentId !== null);
+
+ return (
+
+
+ store.getState().setInspectorTab(t)} label="Inspector views" idPrefix="inspector" size="sm" />
+ {(hasSelection || onClose) && (
+ {
+ store.getState().clearSelection();
+ onClose?.();
+ }}
+ />
+ )}
+
+
+ {!runId ? (
+
+ Open a run to inspect its events and agents.
+
+ ) : tab === 'event' ? (
+
+ ) : tab === 'agent' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function Section({ title, children, aside }: { title: string; children: ReactNode; aside?: ReactNode }) {
+ return (
+
+
+ {title}
+ {aside}
+
+ {children}
+
+ );
+}
+
+function Fact({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+const NotReported = () => not reported ;
+
+/** Hook: the latest exec state, memoised on the received events. */
+function useLatestExec(): ExecState {
+ const store = useStudioStore();
+ const lastSequence = useStudio((s) => s.lastSequence);
+ return useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+}
+
+function AgentLink({ exec, agentId }: { exec: ExecState; agentId: string | null }) {
+ const store = useStudioStore();
+ const world = useStudio((s) => s.world);
+ if (!agentId) return System ;
+ const look = agentLook(exec, world, agentId);
+ return (
+ store.getState().selectAgent(agentId)}>
+
+ {look.name}
+
+ );
+}
+
+function EventLink({ e, exec, onSelect }: { e: AnyEvent; exec: ExecState; onSelect: (seq: number) => void }) {
+ const f = describeEvent(e, exec);
+ return (
+ onSelect(e.sequence)}>
+ #{e.sequence}
+ {e.type}
+ {f.label}
+
+ );
+}
+
+
+/** A tool call as the event at `seq` sees it: input and result summaries (payload text only), provenance, delegation. */
+function CallSection({ call, seq, exec, events, traced }: { call: ToolCallExec; seq: number; exec: ExecState; events: readonly AnyEvent[]; traced: boolean }) {
+ const sourceName = exec.run.source?.kind === 'claude-code' ? 'Claude Code' : 'the source';
+ const finished = call.endedSeq !== null && call.endedSeq <= seq;
+ const subagent = call.category === 'delegate' || call.tool === 'Agent' ? subagentOfCall(events, exec, call.startedEventId) : null;
+ const usage = exec.usageReports.find((u) => u.callId === call.id && u.seq <= seq) ?? null;
+ const prov = (p: ToolCallExec['startProvenance']) => (p === 'reported' ? `reported by ${sourceName}` : p === 'inferred' ? 'reconstructed by Runloop' : 'observed by the runtime');
+ return (
+
+
+ {categoryLabel(call.category)}
+
+
+ ) : undefined
+ }
+ >
+
+
+
+ {call.tool}
+
+
+
+ {call.inputSummary ? {call.inputSummary} : }
+
+ {!finished ? (
+ still running at this event
+ ) : call.outputSummary || call.message ? (
+ {call.outputSummary ?? call.message}
+ ) : (
+
+ )}
+
+ {finished && call.errorType && (
+
+ {call.errorType}
+
+ )}
+
+ {finished && call.durationMs !== null ? (
+ <>
+ {formatDuration(call.durationMs)}
+ {call.endProvenance === 'inferred' && · until Runloop closed it, not a reported run time }
+ >
+ ) : (
+ {finished ? 'not reported' : 'still running'}
+ )}
+
+ {finished && call.exitCode !== null && {call.exitCode} }
+ {traced && (
+
+
+
+ Start: {prov(call.startProvenance)}
+ {call.startProvenance === 'inferred' && inferred }
+
+ {finished && (
+
+ End: {prov(call.endProvenance)}
+ {call.endProvenance === 'inferred' && inferred }
+
+ )}
+
+
+ )}
+ {subagent && (
+
+
+
+ )}
+ {usage && (
+
+
+
+ {usageText(usage)} · {usageParts(usage)}
+
+ {usageSourceText(usage.source)}
+
+
+ )}
+
+
+ );
+}
+
+function EventView() {
+ const store = useStudioStore();
+ const pathname = usePathname() ?? '';
+ const seq = useStudio((s) => s.selectedSeq);
+ const playheadSeq = useStudio((s) => s.playheadSeq);
+ const run = useStudio((s) => s.run);
+ const runId = useStudio((s) => s.runId);
+ const exec = useLatestExec();
+ const [showRaw, setShowRaw] = useState(false);
+ const s = store.getState();
+ const events = s.events();
+ const e = seq !== null ? events[seq - 1] : undefined;
+ const byId = useMemo(() => new Map(events.map((x) => [x.eventId, x])), [events, events.length]);
+
+ if (!e || seq === null) {
+ return (
+
+ Select a row in the timeline or the log, a failure marker on the scrubber, or a character in the world.
+
+ );
+ }
+
+ const select = (n: number) => s.selectEvent(n);
+ const f = describeEvent(e, exec);
+ // The event's own agent (not whichever agent happens to be selected).
+ const agentId = agentOfEvent(e, exec);
+ const task = e.taskId ? exec.tasks[e.taskId] : undefined;
+ const callId = callIdOf(e);
+ const call = callId ? exec.toolCalls.get(callId) : undefined;
+ const parents = parentChain(e, byId);
+ const children = events.filter((x) => x.parentEventId === e.eventId).slice(0, 10);
+ const artifacts = [...new Set([...artifactIdsOf(e), ...(call && call.endedSeq !== null && call.endedSeq <= seq ? call.outputArtifactIds : [])])];
+ const provenance = eventProvenance(e);
+ const runProv = runProvenance(run, exec);
+ const raw = s.rawEvent(seq) ?? e;
+ const tone = f.severity === 'error' ? 'err' : f.severity === 'warn' ? 'warn' : f.severity === 'ok' ? 'ok' : 'muted';
+
+ const copyLink = () => {
+ if (!runId) return;
+ const route = pathname.startsWith(ROUTES.traces) ? ROUTES.traces : ROUTES.world;
+ const url = new URL(linkTo(route, { runId, seq }), window.location.origin).href;
+ navigator.clipboard.writeText(url).then(
+ () => toast({ tone: 'ok', title: 'Link copied', description: `Opens event #${seq} of this run.` }),
+ () => toast({ tone: 'err', title: 'Could not copy the link', description: url }),
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
{f.label}
+
+ {e.type} · #{e.sequence} · {formatClock(s.timeOfSeq(e.sequence))}
+
+
+
+
+
+ {provenance === 'inferred' ? (
+
+ {INFERRED.label}
+
+ ) : (
+
+ {runProv.kind === 'traced' ? 'Reported' : runProv.label}
+
+ )}
+
+ {e.visibility}
+
+ {isUnknownEvent(e) && (
+
+ {e.reason.replace(/_/g, ' ')}
+
+ )}
+ {e.sequence > playheadSeq && after the playhead }
+
+ {f.detail &&
{f.detail}
}
+
+
+ {e.sequence !== playheadSeq && (
+ s.seekToSeq(e.sequence)}>
+ Move playhead here
+
+ )}
+
+ Copy link
+
+
+
+
+
+
+
+
+ {task && (
+
+
+ {task.id} {task.title}
+
+
+ )}
+
+ {new Date(e.timestamp).toLocaleTimeString(undefined, { hour12: false })}
+
+
+ {e.eventId}
+
+ v{e.schemaVersion}
+
+
+
+ {call &&
}
+
+ {call?.metrics && call.endedSeq !== null && call.endedSeq <= seq && (
+
as reported by the test runner}>
+
+
+ {call.metrics.passed} passed
+
+ 0 ? 'err' : undefined}>
+ {call.metrics.failed} failed
+
+
+ {call.metrics.tests} total
+
+
+ {call.failingTests.length > 0 && (
+
+ {call.failingTests.map((t) => (
+
+
+ {t}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {artifacts.length > 0 && (
+
+
+ {artifacts.map((id) => (
+
+ s.openArtifact(id)}>
+
+ {exec.artifacts[id]?.name ?? id}
+ {exec.artifacts[id] && {exec.artifacts[id]!.kind} }
+
+
+ ))}
+
+
+ )}
+
+ {(parents.length > 0 || children.length > 0) && (
+
+ {parents.length > 0 && (
+ <>
+ Caused by
+
+ {parents.map((p) => (
+
+
+
+ ))}
+
+ >
+ )}
+ {children.length > 0 && (
+ <>
+ Led to
+
+ {children.map((c) => (
+
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
setShowRaw((v) => !v)}>
+ {showRaw ? 'Hide' : 'Show'}
+
+ }
+ >
+ {showRaw ? {JSON.stringify(raw, null, 2)} : The event exactly as the runtime sent it.
}
+
+
+ );
+}
+
+function AgentView() {
+ const store = useStudioStore();
+ const selected = useStudio((s) => s.selectedAgentId);
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const world = useStudio((s) => s.world);
+ const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]);
+ const latestExec = useLatestExec();
+ const { exec } = state;
+ const s = store.getState();
+
+ if (!selected) {
+ return (
+
+ Select a character in the world, a name in the roster, or press ] to cycle through agents.
+
+ );
+ }
+ const agent = exec.agents[selected];
+ if (!agent) {
+ return (
+
+ This agent registers later in the run. Move the playhead forward to see it.
+
+ );
+ }
+
+ const look = agentLook(exec, world, agent.id);
+ const character = state.world.characters[agent.id];
+ const station = character?.stationId && world ? world.stationsById[character.stationId] : undefined;
+ const task = agent.currentTaskId ? exec.tasks[agent.currentTaskId] : undefined;
+ const progress = task ? selectTaskProgress(exec, task.id) : null;
+ const actions = selectRecentActions(exec, agent.id, 6);
+ const artifacts = exec.artifactOrder
+ .map((id) => exec.artifacts[id]!)
+ .filter((a) => a.agentId === agent.id)
+ .reverse();
+ const latest = latestEventOf(s.events(), latestExec, agent.id, seq);
+ const ended = runHasEnded(exec);
+
+ return (
+
+
+
+ {agent.name.slice(0, 1)}
+
+
+
{agent.name}
+
{agent.role}
+
+
+
+ {character && }
+
+ {ended ? 'last state' : 'state'}: {runtimeStatusView(agent.state, ended).text}
+
+ {station && at {station.label} }
+ {!agent.embodied && no character in the world }
+
+ {agent.stateReason &&
{agent.stateReason}
}
+
+ {latest !== null && (
+ s.selectEvent(latest)}>
+ Latest event #{latest}
+
+ )}
+
+
+
+
+
+ {agent.model ?? agent.usage?.model ?? }
+ {!agent.model && agent.usage?.model && · {usageSourceText(agent.usage.source)} }
+
+
+ {agent.usage ? (
+
+ {formatTokens(usageTotal(agent.usage))} tokens
+ {usageParts(agent.usage)}
+ {usageSourceText(agent.usage.source)}
+
+ ) : (
+
+
+ {isTracedExec(exec) && agent.role === 'main agent' && · Claude Code hooks do not report the main agent’s usage }
+
+ )}
+
+
+ {agent.tools.length ? (
+
+ {agent.tools.map((t) => (
+
+ {t}
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {agent.id}
+
+
+
+
+
+ {task ? (
+
+
+ {task.title}
+
+
+
+ {task.id} · {KIND_LABEL[task.kind] ?? task.kind} · attempt {Math.max(1, task.attempts)}
+
+ {task.blockedReason && task.status === 'blocked' &&
Blocked: {task.blockedReason} }
+
+
+
+
+ {task.plannedSteps !== null ? `${Math.min(task.stepsDoneThisAttempt, task.plannedSteps)} of ${task.plannedSteps} planned steps` : 'No step plan reported'}
+
+
+ ) : (
+ No task at the playhead.
+ )}
+
+
+
+ {actions.length === 0 ? (
+ No tool calls yet.
+ ) : (
+
+ {actions.map((c) => {
+ const view = callStatusView(c.status);
+ return (
+
+ s.selectEvent(c.endedSeq ?? c.startedSeq, { seek: true })}>
+
+
+
+
+
+ {c.tool} {c.inputSummary}
+
+
+ {view.text}:
+ {c.status === 'running' ? 'running…' : (c.outputSummary ?? c.message ?? view.text)}
+ {c.durationMs !== null && ` · ${formatDuration(c.durationMs)}`}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {artifacts.length === 0 ? (
+ None yet.
+ ) : (
+
+ {artifacts.map((a) => (
+
+ s.openArtifact(a.artifactId)}>
+
+ {a.name}
+ {a.kind}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function RunView() {
+ const store = useStudioStore();
+ const run = useStudio((s) => s.run);
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const world = useStudio((s) => s.world);
+ const exec = useMemo(() => store.getState().stateAt(seq).exec, [store, seq, lastSequence]);
+ const latest = useLatestExec();
+ const s = store.getState();
+ if (!run) {
+ return (
+
+ The summary appears once the run’s first events arrive.
+
+ );
+ }
+ const prov = runProvenance(run, latest);
+ const traced = prov.kind === 'traced' || isTracedRun(run);
+ const ended = runHasEnded(exec);
+ const failures = latest.incidents;
+
+ return (
+
+
+
+
{run.title}
+
Started {formatWhen(run.createdAt)}
+
+
+
+
+
+ {prov.label}
+
+
+
{prov.detail}
+
+ {traced && (
+
+ )}
+
+
+
+ {failures.length > 0 && (
+
+
+ {failures.map((i) => (
+
+ s.selectEvent(i.seq, { seek: true })}>
+
+
+
+
+
+ {i.kind.replace(/_/g, ' ')} · #{i.seq}
+
+
+ {i.message}
+ {i.resolvedSeq !== null
+ ? i.resolution === 'later_success'
+ ? ` · a later call succeeded at #${i.resolvedSeq} (inferred)`
+ : ` · resolved at #${i.resolvedSeq} (its task finished)`
+ : ''}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ {exec.taskOrder.length === 0 ? (
+ No tasks yet.
+ ) : (
+
+ {exec.taskOrder.map((id) => {
+ const t = exec.tasks[id]!;
+ const owner = t.ownerId ? agentLook(exec, world, t.ownerId) : null;
+ return (
+
+ t.ownerId && s.selectAgent(t.ownerId)} disabled={!t.ownerId}>
+
+
+ {t.title}
+
+
+
+ {t.id} · {KIND_LABEL[t.kind] ?? t.kind}
+ {owner ? ` · ${owner.name}` : ''}
+ {t.attempts > 1 ? ` · ${t.attempts} attempts` : ''}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/studio/src/components/LogsPanel.module.css b/apps/studio/src/components/LogsPanel.module.css
new file mode 100644
index 0000000..1b13e5e
--- /dev/null
+++ b/apps/studio/src/components/LogsPanel.module.css
@@ -0,0 +1,148 @@
+.logs {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-1) var(--rl-space-3) var(--rl-space-1) var(--rl-space-4);
+}
+.note {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0 var(--rl-space-2) var(--rl-space-3);
+}
+.spacer {
+ list-style: none;
+}
+.entry {
+ position: relative;
+}
+/* After the playhead: quieter text that still reads at AA (no opacity), and a line at the playhead. */
+.entry[data-future] .type {
+ color: var(--rl-text-muted);
+}
+.entry[data-future] .actor,
+.entry[data-future] .seq,
+.entry[data-future] .clock {
+ color: var(--rl-text-faint);
+}
+.entry[data-playhead]::before {
+ content: '';
+ position: absolute;
+ left: var(--rl-space-2);
+ right: var(--rl-space-2);
+ top: -1px;
+ border-top: 1px dashed var(--rl-line-strong);
+}
+.summary {
+ display: grid;
+ grid-template-columns: 44px 44px minmax(150px, 0.9fr) minmax(0, 1fr) auto;
+ align-items: center;
+ column-gap: var(--rl-space-3);
+ width: 100%;
+ min-height: 32px;
+ padding: 4px var(--rl-space-2);
+ border-radius: var(--rl-radius-control);
+ text-align: left;
+ font-size: var(--rl-text-xs);
+ cursor: pointer;
+ list-style: none;
+}
+.summary::-webkit-details-marker {
+ display: none;
+}
+.summary:hover {
+ background: var(--rl-surface-2);
+}
+.summary:hover .seq,
+.summary:hover .clock {
+ color: var(--rl-text-muted);
+}
+.entry[data-selected] .summary {
+ background: var(--rl-surface-2);
+ box-shadow: inset 2px 0 0 var(--rl-accent-text);
+}
+.entry[data-selected] .seq,
+.entry[data-selected] .clock,
+.entry[data-selected] .actor,
+.entry[data-selected] .task {
+ color: var(--rl-text-muted);
+}
+.seq,
+.clock {
+ color: var(--rl-text-faint);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.type {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--rl-text);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-xs);
+}
+.actor {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--rl-text-muted);
+}
+.task {
+ color: var(--rl-text-faint);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+/* Room for one chip on every row, so the agent column lines up whether or not a row has a chip. */
+.chips {
+ display: flex;
+ gap: var(--rl-space-1);
+ justify-content: flex-end;
+ min-width: 104px;
+}
+.invalid[open] .summary {
+ background: var(--rl-surface-2);
+}
+.json {
+ margin: 4px var(--rl-space-2) var(--rl-space-2);
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-canvas);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ line-height: 1.6;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+@media (max-width: 760px) {
+ .summary {
+ grid-template-columns: 40px minmax(0, 1fr) auto;
+ row-gap: 2px;
+ }
+ .clock {
+ display: none;
+ }
+ .actor {
+ grid-column: 2;
+ }
+ .chips {
+ min-width: 0;
+ grid-column: 3;
+ grid-row: 1 / span 2;
+ }
+}
diff --git a/apps/studio/src/components/LogsPanel.tsx b/apps/studio/src/components/LogsPanel.tsx
new file mode 100644
index 0000000..9ae3676
--- /dev/null
+++ b/apps/studio/src/components/LogsPanel.tsx
@@ -0,0 +1,169 @@
+'use client';
+import { formatClock } from '@runloop-world/core';
+import { isUnknownEvent } from '@runloop-world/events';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { EMPTY_FILTER, activeFilterCount } from '../state/filters';
+import { eventProvenance } from '../state/selection';
+import { agentLook } from '../state/view';
+import { Button } from '../ui/Button';
+import { AgentDot, Chip } from '../ui/Chip';
+import { EmptyState, SkeletonRows } from '../ui/States';
+import { listKeyHandler, useRovingRow, useVirtualRows } from '../ui/useVirtualRows';
+import styles from './LogsPanel.module.css';
+import type { TraceModel } from './useTraceModel';
+
+/** Every recorded event, one line each (plus envelopes that failed validation). Selecting one opens it in the inspector. */
+export default function LogsPanel({ model }: { model: TraceModel }) {
+ const store = useStudioStore();
+ const playheadSeq = useStudio((s) => s.playheadSeq);
+ const selectedSeq = useStudio((s) => s.selectedSeq);
+ const world = useStudio((s) => s.world);
+ const raw = useStudio((s) => s.raw);
+ const conn = useStudio((s) => s.conn);
+ const filter = useStudio((s) => s.filter);
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
+ const listRef = useRef(null);
+ const { events, visibleEvents, exec } = model;
+ const filtered = activeFilterCount(filter) > 0;
+ const keys = useMemo(() => visibleEvents.map((e) => e.eventId), [visibleEvents]);
+ const v = useVirtualRows(listRef, keys, 34);
+ const selected = selectedSeq === null ? -1 : visibleEvents.findIndex((e) => e.sequence === selectedSeq);
+ // One Tab stop for the whole list (arrow keys move within it): the selected entry, else the playhead's.
+ let atPlayhead = -1;
+ for (let i = visibleEvents.length - 1; i >= 0 && atPlayhead < 0; i--) if (visibleEvents[i]!.sequence <= playheadSeq) atPlayhead = i;
+ const roving = useRovingRow(selected >= 0 ? selected : Math.max(0, atPlayhead));
+ const tabStop = Math.min(roving.tabStop, visibleEvents.length - 1);
+
+ useEffect(() => {
+ if (selected >= 0) v.scrollToIndex(selected);
+ }, [selected]);
+
+ const copyJsonl = async () => {
+ const s = store.getState();
+ const lines = [...events.map((e) => JSON.stringify(s.rawEvent(e.sequence) ?? e)), ...raw.map((r) => JSON.stringify(r.raw))];
+ try {
+ await navigator.clipboard.writeText(lines.join('\n') + '\n');
+ setCopyState('copied');
+ } catch {
+ setCopyState('failed');
+ }
+ setTimeout(() => setCopyState('idle'), 2000);
+ };
+
+ if (events.length === 0 && raw.length === 0) {
+ if (conn === 'loading') return ;
+ return (
+
+ Each event the runtime records appears here with its raw JSON.
+
+ );
+ }
+
+ return (
+
+
+
+ {raw.length > 0 ? `${raw.length} envelope${raw.length > 1 ? 's' : ''} failed validation (listed at the end)` : 'Raw events as recorded'}
+
+ void copyJsonl()}>
+ {copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy JSONL'}
+
+
+ {visibleEvents.length === 0 && filtered ? (
+
store.getState().setFilter(EMPTY_FILTER)}>
+ Clear filters
+
+ }
+ />
+ ) : (
+
+ {v.padTop > 0 && }
+ {visibleEvents.slice(v.start, v.end).map((e, k) => {
+ const i = v.start + k;
+ const unknown = isUnknownEvent(e);
+ const agentId = model.agentOf(e);
+ const look = agentLook(exec, world, agentId ?? 'system');
+ const isSelected = i === selected;
+ const future = e.sequence > playheadSeq;
+ return (
+
+ roving.onFocusRow(i)}
+ className={styles.summary}
+ aria-current={isSelected ? 'true' : undefined}
+ aria-label={future ? `#${e.sequence} ${e.type}, after the playhead` : undefined}
+ onClick={() => store.getState().selectEvent(e.sequence)}
+ >
+ #{e.sequence}
+ {formatClock(store.getState().timeOfSeq(e.sequence))}
+ {e.type}
+
+
+ {look.name}
+ {e.taskId && {e.taskId} }
+
+
+ {eventProvenance(e) === 'inferred' && (
+
+ inferred
+
+ )}
+ {e.visibility !== 'public' && (
+
+ {e.visibility}
+
+ )}
+ {unknown && (
+
+ {e.reason.replace(/_/g, ' ')}
+
+ )}
+
+
+
+ );
+ })}
+ {v.padBottom > 0 && }
+ {!filtered &&
+ raw.map((r) => (
+
+
+
+ {r.sequence !== null ? `#${r.sequence}` : '#?'}
+ —
+ invalid envelope
+ {r.error}
+
+
+ not recorded as an event
+
+
+
+ {typeof r.raw === 'string' ? r.raw : JSON.stringify(r.raw, null, 2)}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/studio/src/components/MetadataPanel.tsx b/apps/studio/src/components/MetadataPanel.tsx
new file mode 100644
index 0000000..f03d958
--- /dev/null
+++ b/apps/studio/src/components/MetadataPanel.tsx
@@ -0,0 +1,118 @@
+'use client';
+import { isUnknownEvent } from '@runloop-world/events';
+import { useMemo, type ReactNode } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { runProvenance } from '../state/provenance';
+import { formatDuration } from '../state/timeline';
+import { runStatusView } from '../state/view';
+import { Chip } from '../ui/Chip';
+import { EmptyState } from '../ui/States';
+import styles from './InfoPanels.module.css';
+import SourceFacts from './SourceFacts';
+
+const NOT_REPORTED = not reported ;
+
+function Row({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children ?? NOT_REPORTED}
+
+ );
+}
+
+/** Run facts: ids, provenance, adapter, trace integrity and world. */
+export default function MetadataPanel() {
+ const store = useStudioStore();
+ const run = useStudio((s) => s.run);
+ const world = useStudio((s) => s.world);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const durationMs = useStudio((s) => s.durationMs);
+ const raw = useStudio((s) => s.raw);
+ const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+ const versions = useMemo(
+ () => [...new Set(store.getState().events().map((e) => e.schemaVersion))].sort().join(', '),
+ [store, lastSequence],
+ );
+ const unknownCount = useMemo(() => store.getState().events().filter(isUnknownEvent).length, [store, lastSequence]);
+
+ if (!run) {
+ return (
+
+ Open a run to see its metadata.
+
+ );
+ }
+ const r = exec.run;
+ const prov = runProvenance(run, exec);
+
+ return (
+
+
+
+ {prov.label}
+
+ {prov.detail}
+
+
+ Run
+
+
+ {run.runId}
+
+ {run.title}
+
+ {run.missionId}
+
+
+ {runStatusView(run.status, run.paused, prov.kind === 'traced').text}
+ {prov.kind === 'traced' && run.status === 'stopped' ? ' (recorded as stopped)' : ''}
+
+ {r.startedAt ?? run.createdAt}
+ {r.endedAt ?? run.endedAt ?? (run.status === 'running' ? still running : null)}
+ {formatDuration(durationMs)}
+ {r.summary}
+ {prov.kind !== 'traced' && {r.seed !== null && r.seed !== undefined ? {r.seed} : null}
}
+ {prov.kind !== 'traced' && {r.workspace ? {r.workspace} : null}
}
+
+
+ {prov.kind === 'traced' && (
+
+ )}
+
+ Adapter
+
+ {r.adapter?.label ?? run.adapterLabel}
+ {r.adapter?.kind}
+ {r.adapter ? {r.adapter.id} : null}
+
+
+
+ Trace
+
+ {versions || null}
+ {lastSequence}
+ {unknownCount}
+ {raw.length}
+ {exec.anomalies.length}
+ {exec.incidents.length}
+
+
+
+ World
+
+
+ {world?.def.id ?? run.worldId}
+
+ {world?.def.name}
+ {world ? `${world.width} × ${world.height} cells` : null}
+ {world ? `${world.def.tickHz} Hz, ${world.def.ticksPerCell} ticks per cell` : null}
+ {world?.def.stations.length}
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/PayloadView.module.css b/apps/studio/src/components/PayloadView.module.css
new file mode 100644
index 0000000..6785058
--- /dev/null
+++ b/apps/studio/src/components/PayloadView.module.css
@@ -0,0 +1,64 @@
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+.fields[data-depth]:not([data-depth='0']) {
+ padding-left: var(--rl-space-3);
+ box-shadow: inset 1px 0 0 var(--rl-line-strong);
+}
+.field {
+ display: grid;
+ grid-template-columns: minmax(88px, 34%) minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ min-width: 0;
+ font-size: var(--rl-text-xs);
+}
+.field dt {
+ color: var(--rl-text-muted);
+}
+.field dd {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--rl-text);
+}
+.none {
+ color: var(--rl-text-faint);
+ font-style: italic;
+}
+.raw {
+ color: var(--rl-text-faint);
+}
+.code {
+ font-size: var(--rl-text-2xs);
+ color: var(--rl-text);
+ overflow-wrap: anywhere;
+}
+.block {
+ margin: 0;
+ padding: var(--rl-space-2);
+ max-height: 220px;
+ overflow: auto;
+ border-radius: var(--rl-radius-sm);
+ background: var(--rl-canvas);
+ font-size: var(--rl-text-2xs);
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
+.list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.item {
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: var(--rl-surface-2);
+ font-size: var(--rl-text-2xs);
+}
+.nested {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
diff --git a/apps/studio/src/components/PayloadView.tsx b/apps/studio/src/components/PayloadView.tsx
new file mode 100644
index 0000000..6167708
--- /dev/null
+++ b/apps/studio/src/components/PayloadView.tsx
@@ -0,0 +1,86 @@
+import type { ReactNode } from 'react';
+import { formatDuration } from '../state/timeline';
+import styles from './PayloadView.module.css';
+
+const MAX_DEPTH = 4;
+
+/** "outputSummary" → "Output summary". */
+export function humanizeKey(key: string): string {
+ const spaced = key.replace(/_/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
+const looksLikeId = (s: string) => s.length <= 80 && !/\s/.test(s) && /[-_.:/#]|\d/.test(s);
+
+function Value({ k, v, depth }: { k: string; v: unknown; depth: number }): ReactNode {
+ if (v === null || v === undefined) return not reported ;
+ if (typeof v === 'boolean') return {v ? 'yes' : 'no'} ;
+ if (typeof v === 'number') {
+ if (/ms$/i.test(k) && Number.isFinite(v)) {
+ return (
+
+ {formatDuration(v)} ({v} ms)
+
+ );
+ }
+ if (/bytes$/i.test(k)) return {v.toLocaleString()} bytes ;
+ return {v} ;
+ }
+ if (typeof v === 'string') {
+ if (v === '') return empty ;
+ if (v.length > 140 || v.includes('\n')) return {v} ;
+ return looksLikeId(v) ? {v} : {v} ;
+ }
+ if (Array.isArray(v)) {
+ if (v.length === 0) return none ;
+ if (v.every((x) => typeof x === 'string' || typeof x === 'number')) {
+ return (
+
+ {v.map((x, i) => (
+
+ {String(x)}
+
+ ))}
+
+ );
+ }
+ if (depth >= MAX_DEPTH) return {JSON.stringify(v)};
+ return (
+
+ {v.map((x, i) => (
+
+
+
+ ))}
+
+ );
+ }
+ if (typeof v === 'object') {
+ if (depth >= MAX_DEPTH) return {JSON.stringify(v)};
+ return } depth={depth + 1} />;
+ }
+ return {String(v)} ;
+}
+
+function Fields({ value, depth }: { value: Record; depth: number }) {
+ const entries = Object.entries(value);
+ if (entries.length === 0) return no fields ;
+ return (
+
+ {entries.map(([k, v]) => (
+
+
{humanizeKey(k)}
+
+
+
+
+ ))}
+
+ );
+}
+
+/** An event payload as labelled fields: exactly what was recorded, with missing values said in words. */
+export default function PayloadView({ payload }: { payload: unknown }) {
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) return } depth={0} />;
+ return ;
+}
diff --git a/apps/studio/src/components/ReplayBar.module.css b/apps/studio/src/components/ReplayBar.module.css
new file mode 100644
index 0000000..f99c53f
--- /dev/null
+++ b/apps/studio/src/components/ReplayBar.module.css
@@ -0,0 +1,223 @@
+.bar {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ min-height: 48px;
+ padding: 0 var(--rl-space-3);
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+}
+.bar[data-glass] {
+ background: var(--rl-glass);
+ backdrop-filter: blur(14px) saturate(1.2);
+ box-shadow: var(--rl-shadow-pop), 0 0 0 1px var(--rl-glass-line);
+}
+.transport {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+.play {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--rl-text);
+ color: var(--rl-canvas);
+ transition: transform var(--rl-dur-instant) var(--rl-ease-out);
+}
+.play:hover:not(:disabled) {
+ transform: scale(1.05);
+}
+.play:disabled {
+ opacity: 0.4;
+}
+.clock {
+ flex: none;
+ min-width: 96px;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-xs);
+}
+
+.scrubber {
+ position: relative;
+ flex: 1;
+ min-width: 120px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+}
+.range {
+ --pct: 0%;
+ width: 100%;
+ height: 32px;
+ margin: 0;
+ background: transparent;
+ appearance: none;
+ cursor: pointer;
+}
+.range:disabled {
+ cursor: not-allowed;
+}
+.range::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: 2px;
+ background: linear-gradient(90deg, var(--rl-text-muted) 0 var(--pct), var(--rl-surface-3) var(--pct) 100%);
+}
+.range::-moz-range-track {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rl-surface-3);
+}
+.range::-moz-range-progress {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rl-text-muted);
+}
+.range::-webkit-slider-thumb {
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ margin-top: -5px;
+ border-radius: 50%;
+ background: var(--rl-text);
+ box-shadow: 0 0 0 3px var(--rl-surface);
+}
+.range::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border: 0;
+ border-radius: 50%;
+ background: var(--rl-text);
+ box-shadow: 0 0 0 3px var(--rl-surface);
+}
+.range:focus-visible {
+ box-shadow: none;
+}
+.range:focus-visible::-webkit-slider-thumb {
+ box-shadow: var(--rl-focus-ring);
+}
+.range:focus-visible::-moz-range-thumb {
+ box-shadow: var(--rl-focus-ring);
+}
+
+.markers {
+ position: absolute;
+ inset: 0 7px;
+ pointer-events: none;
+}
+.marker {
+ position: absolute;
+ top: -1px;
+ display: grid;
+ place-items: center;
+ width: 16px;
+ height: 16px;
+ margin-left: -8px;
+ border-radius: 4px;
+ background: var(--rl-failure);
+ color: var(--rl-canvas);
+ pointer-events: auto;
+ transition: transform var(--rl-dur-instant) var(--rl-ease-out);
+}
+.marker:hover {
+ transform: translateY(-1px) scale(1.1);
+}
+.marker[data-resolved='true'] {
+ background: var(--rl-surface-3);
+ color: var(--rl-text-muted);
+}
+
+.speeds {
+ display: flex;
+ gap: 1px;
+ padding: 2px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.speed {
+ min-width: 34px;
+ height: 24px;
+ padding: 0 6px;
+ border-radius: 6px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.speed:hover {
+ color: var(--rl-text);
+}
+.speed[aria-pressed='true'] {
+ background: var(--rl-surface);
+ color: var(--rl-text);
+ box-shadow: var(--rl-shadow-card);
+}
+
+.live {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+}
+.newChip {
+ height: 24px;
+ padding: 0 8px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-accent-soft);
+ color: var(--rl-accent-text);
+ font-size: var(--rl-text-xs);
+ font-weight: 600;
+}
+.liveBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 28px;
+ padding: 0 10px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 600;
+}
+.liveBtn:hover:not(:disabled) {
+ color: var(--rl-text);
+}
+.liveBtn[aria-pressed='true'] {
+ color: var(--rl-text);
+}
+.liveDot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--rl-text-faint);
+}
+/* Lime marks active execution: only while a running run streams live. */
+.liveDot[data-on='true'] {
+ background: var(--rl-accent-text);
+ box-shadow: 0 0 0 3px var(--rl-accent-soft);
+}
+
+@media (max-width: 1100px) {
+ .speeds {
+ display: none;
+ }
+}
+@media (max-width: 760px) {
+ .bar {
+ flex-wrap: wrap;
+ padding: var(--rl-space-2);
+ gap: var(--rl-space-2);
+ }
+ .scrubber {
+ order: 5;
+ flex-basis: 100%;
+ }
+ .clock {
+ min-width: 0;
+ }
+ .live {
+ margin-left: auto;
+ }
+}
diff --git a/apps/studio/src/components/ReplayBar.tsx b/apps/studio/src/components/ReplayBar.tsx
new file mode 100644
index 0000000..8b98036
--- /dev/null
+++ b/apps/studio/src/components/ReplayBar.tsx
@@ -0,0 +1,126 @@
+'use client';
+import { formatClock } from '@runloop-world/core';
+import { useMemo, type CSSProperties } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { useTicker } from '../state/hooks';
+import { SPEEDS } from '../state/store';
+import { formatClockPair } from '../state/view';
+import { Button } from '../ui/Button';
+import { Icon } from '../ui/Icon';
+import styles from './ReplayBar.module.css';
+
+/** Transport: previous / play / next, speed, scrubber with incident markers, live toggle. */
+export default function ReplayBar({ glass }: { glass?: boolean }) {
+ const store = useStudioStore();
+ // Not the whole cursor: replay playback replaces it every animation frame.
+ const live = useStudio((s) => s.cursor.mode === 'live');
+ const cursorPlaying = useStudio((s) => s.cursor.mode === 'replay' && s.cursor.playing);
+ useStudio((s) => (s.cursor.mode === 'replay' ? Math.floor(s.cursor.tMs / 100) : -1)); // re-render per 100 ms of playhead
+ const playheadSeq = useStudio((s) => s.playheadSeq);
+ const runId = useStudio((s) => s.runId);
+ const ended = useStudio((s) => s.ended);
+ const settleMs = useStudio((s) => s.settleMs);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const newSinceReplay = useStudio((s) => s.newSinceReplay);
+ const speed = useStudio((s) => s.speed);
+ const s = store.getState();
+
+ const t = s.playheadMs();
+ const end = s.endMs();
+ const settled = ended && t >= settleMs;
+ // The live clock moves with wall time, not with store updates.
+ useTicker(live && runId !== null && !settled, 250);
+
+ const disabled = runId === null || lastSequence === 0;
+ const playing = !disabled && (live ? !settled : cursorPlaying);
+
+ const incidents = useMemo(() => {
+ const st = store.getState();
+ return st.stateAt(lastSequence).exec.incidents.map((i) => ({ ...i, tMs: st.timeOfSeq(i.seq) }));
+ }, [store, lastSequence]);
+
+ const pct = (ms: number) => `${end > 0 ? Math.min(100, (ms / end) * 100) : 0}%`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {formatClockPair(t, end)}
+
+
+
+
s.seek(Number(e.target.value))}
+ aria-label="Playhead time"
+ aria-valuetext={`${formatClockPair(t, end)}, event ${playheadSeq} of ${lastSequence}`}
+ style={{ '--pct': pct(t) } as CSSProperties}
+ />
+
+ {incidents.map((i) => (
+ s.selectEvent(i.seq, { seek: true })}
+ aria-label={`Failure at ${formatClock(i.tMs)}: ${i.message}`}
+ title={`${formatClock(i.tMs)} · ${i.message}`}
+ >
+
+
+ ))}
+
+
+
+
+ {SPEEDS.map((v) => (
+ s.setSpeed(v)}>
+ {v}×
+
+ ))}
+
+
+
+ {!live && newSinceReplay > 0 && (
+
+ {newSinceReplay} new
+
+ )}
+
+
+ {live ? (ended ? 'Latest' : 'Live') : 'Go live'}
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/RunHeader.module.css b/apps/studio/src/components/RunHeader.module.css
new file mode 100644
index 0000000..ee08c2f
--- /dev/null
+++ b/apps/studio/src/components/RunHeader.module.css
@@ -0,0 +1,123 @@
+.header {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--rl-space-2) var(--rl-space-4);
+ padding: 0 var(--rl-space-1);
+}
+.title {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ min-width: 0;
+ flex: 1 1 360px;
+}
+.number {
+ flex: none;
+ color: var(--rl-text-faint);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-sm);
+}
+.titleText {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+.subtitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.activity {
+ font-family: var(--rl-font-sans);
+}
+.h1 {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--rl-text-h3);
+ font-weight: 600;
+ letter-spacing: -0.015em;
+}
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ margin-left: auto;
+}
+.runList {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 300px;
+}
+.runItem {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--rl-space-2);
+ width: 100%;
+ min-height: 32px;
+ padding: 0 8px;
+ border-radius: var(--rl-radius-sm);
+ text-align: left;
+ font-size: var(--rl-text-xs);
+}
+.runItem:hover {
+ background: var(--rl-surface-2);
+}
+.runItem[aria-current='true'] {
+ background: var(--rl-accent-soft);
+}
+.runIcon {
+ display: grid;
+ place-items: center;
+ color: var(--rl-text-muted);
+}
+.runIcon[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.runIcon[data-tone='err'] {
+ color: var(--rl-failure);
+}
+.runIcon[data-tone='live'] {
+ color: var(--rl-accent-text);
+}
+.runIcon[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.runName {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 4px 0;
+}
+.runSub {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.runWhen {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+}
+@media (max-width: 760px) {
+ .h1 {
+ font-size: var(--rl-text-lg);
+ }
+ .actions {
+ margin-left: 0;
+ }
+}
diff --git a/apps/studio/src/components/RunHeader.tsx b/apps/studio/src/components/RunHeader.tsx
new file mode 100644
index 0000000..6235a7f
--- /dev/null
+++ b/apps/studio/src/components/RunHeader.tsx
@@ -0,0 +1,150 @@
+'use client';
+import { useMemo, useState } from 'react';
+import { stopRun } from '../lib/api';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES } from '../state/deeplink';
+import { formatWhen, runNumber } from '../state/format';
+import { selectRun, useTicker } from '../state/hooks';
+import { runProvenance } from '../state/provenance';
+import { activityText, isTracedRun, runSubtitle } from '../state/runs';
+import { runStatusView } from '../state/view';
+import { Button, ButtonLink } from '../ui/Button';
+import { Chip, StatusBadge } from '../ui/Chip';
+import { ConfirmDialog } from '../ui/ConfirmDialog';
+import { GlyphIcon, Icon } from '../ui/Icon';
+import { Popover } from '../ui/Popover';
+import { toast } from '../ui/Toast';
+import styles from './RunHeader.module.css';
+
+/** Run title, status, provenance and the run's actions; `page` decides the cross-link. */
+export default function RunHeader({ page }: { page: 'world' | 'traces' }) {
+ const store = useStudioStore();
+ const run = useStudio((s) => s.run);
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const activeRunId = useStudio((s) => s.runtime.activeRunId);
+ const warnings = useStudio((s) => s.warnings);
+ const gap = useStudio((s) => s.gap);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+ const [confirmStop, setConfirmStop] = useState(false);
+ const [stopping, setStopping] = useState(false);
+
+ const n = runNumber(runs, runId);
+ const prov = runProvenance(run, exec);
+ const traced = isTracedRun(run) || prov.kind === 'traced';
+ const canStop = run !== null && run.status === 'running' && activeRunId === run.runId;
+ const live = run?.status === 'running';
+ useTicker(live && traced, 15_000);
+ const subtitle = run ? runSubtitle(run) : null;
+ const activity = run && live && traced ? activityText(run, Date.now()) : null;
+
+ const onStop = async () => {
+ if (!run) return;
+ setStopping(true);
+ try {
+ await stopRun(run.runId);
+ toast({ tone: 'ok', title: 'Run stopped', description: 'Everything recorded so far is kept and can be replayed.' });
+ } catch (err) {
+ toast({ tone: 'err', title: 'Could not stop the run', description: `${err instanceof Error ? err.message : String(err)}. The run keeps going; try again.` });
+ } finally {
+ setStopping(false);
+ setConfirmStop(false);
+ }
+ };
+
+ return (
+
+
+
{n !== null ? `#${n}` : '#'}
+
+
+ {run?.title ?? 'Loading run…'}
+
+ {(subtitle || activity) && (
+
+ {subtitle}
+ {activity && · {activity} }
+
+ )}
+
+ {run &&
}
+ {run && (
+
+ {prov.label}
+
+ )}
+ {gap && (
+
+ waiting for #{gap.from}
+ {gap.to > gap.from ? `–${gap.to}` : ''}
+
+ )}
+ {warnings.length > 0 && (
+
+ {warnings.length} warning{warnings.length > 1 ? 's' : ''}
+
+ )}
+
+
+ {runs.length > 1 && (
+
+
+ {runs.slice(0, 20).map((r, i) => {
+ const rTraced = isTracedRun(r);
+ const view = runStatusView(r.status, r.paused, rTraced);
+ const sub = runSubtitle(r);
+ return (
+
+ selectRun(store, r.runId)}>
+
+
+ {view.text}
+
+
+ #{runs.length - i} · {r.title}
+ {sub && {sub} }
+
+
+
+ {formatWhen(r.createdAt)}
+
+
+
+ );
+ })}
+
+
+ )}
+ {page === 'world' ? (
+
+ Investigate in Traces
+
+ ) : (
+
+ Open Live World
+
+ )}
+ {canStop && (
+
setConfirmStop(true)}>
+ Stop run
+
+ )}
+
+ setConfirmStop(false)}
+ onConfirm={() => void onStop()}
+ impact={
+ <>
+ The demo mission stops at its next step and ends as “stopped”. A stopped run cannot be resumed.
+ Every event recorded so far is kept and can still be replayed.
+ >
+ }
+ />
+
+ );
+}
diff --git a/apps/studio/src/components/RunMetrics.module.css b/apps/studio/src/components/RunMetrics.module.css
new file mode 100644
index 0000000..be2b532
--- /dev/null
+++ b/apps/studio/src/components/RunMetrics.module.css
@@ -0,0 +1,216 @@
+.metrics {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-5);
+}
+.note {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
+ gap: var(--rl-space-2);
+}
+.metrics[data-variant='compact'] .tiles {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.tile {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.tileLabel {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.tileValue {
+ font-size: var(--rl-text-lg);
+ font-weight: 600;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+.metrics[data-variant='full'] .tileValue {
+ font-size: var(--rl-text-h3);
+}
+.tileValue[data-tone='err'] {
+ color: var(--rl-failure);
+}
+.tileValue[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.tileValue[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.tileValue .none {
+ font-size: var(--rl-text-sm);
+ font-weight: 400;
+}
+.tileSub {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+}
+.tileAction {
+ margin-top: 2px;
+ font-size: var(--rl-text-2xs);
+}
+.inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.link {
+ color: var(--rl-text);
+ font-weight: 500;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ border-radius: 4px;
+}
+.none {
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-sans);
+ font-style: italic;
+}
+.muted {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.foot {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+}
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+}
+.h3 {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.bars {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.barRow {
+ display: grid;
+ grid-template-columns: minmax(96px, 140px) minmax(40px, 1fr) 36px minmax(64px, auto);
+ align-items: center;
+ gap: var(--rl-space-3);
+ width: 100%;
+ min-height: 30px;
+ padding: 0 var(--rl-space-2);
+ border-radius: var(--rl-radius-sm);
+ text-align: left;
+ font-size: var(--rl-text-xs);
+}
+button.barRow:hover {
+ background: var(--rl-surface-2);
+}
+.barLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.barLabel svg {
+ flex: none;
+ color: var(--rl-text-muted);
+}
+.barTrack {
+ height: 8px;
+ border-radius: 4px;
+ background: var(--rl-surface-2);
+ overflow: hidden;
+}
+button.barRow:hover .barTrack {
+ background: var(--rl-surface-3);
+}
+.barFill {
+ display: block;
+ height: 100%;
+ min-width: 4px;
+ border-radius: 4px;
+ background: var(--rl-text-muted);
+}
+.barCount {
+ font-family: var(--rl-font-mono);
+ text-align: right;
+}
+.barErrors {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ white-space: nowrap;
+}
+.barErrors[data-some] {
+ color: var(--rl-failure);
+}
+.tableWrap {
+ overflow-x: auto;
+}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--rl-text-xs);
+}
+.table th,
+.table td {
+ padding: 7px var(--rl-space-2);
+ text-align: left;
+ white-space: nowrap;
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+}
+.table thead th {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.table td code {
+ font-size: var(--rl-text-2xs);
+}
+.right {
+ text-align: right !important;
+ font-family: var(--rl-font-mono);
+}
+.agent {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 500;
+ border-radius: 4px;
+}
+.agent:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.metrics[data-variant='compact'] .barRow {
+ grid-template-columns: minmax(80px, 110px) minmax(30px, 1fr) 28px auto;
+ gap: var(--rl-space-2);
+}
+
+.table td.wrap {
+ white-space: normal;
+ min-width: 180px;
+}
diff --git a/apps/studio/src/components/RunMetrics.tsx b/apps/studio/src/components/RunMetrics.tsx
new file mode 100644
index 0000000..023631c
--- /dev/null
+++ b/apps/studio/src/components/RunMetrics.tsx
@@ -0,0 +1,345 @@
+'use client';
+import type { ToolCategory } from '@runloop-world/events';
+import { useMemo, type ReactNode } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { EMPTY_FILTER } from '../state/filters';
+import { CATEGORY_ICON, categoryLabel, formatInt, formatTokens, usageParts } from '../state/labels';
+import { computeRunMetrics, type CountRow, type RunMetrics as Metrics } from '../state/metrics';
+import { formatDuration } from '../state/timeline';
+import { agentLook } from '../state/view';
+import { AgentDot, Chip } from '../ui/Chip';
+import { Icon, type IconName } from '../ui/Icon';
+import { EmptyState, SkeletonRows } from '../ui/States';
+import styles from './RunMetrics.module.css';
+
+const NotReported = ({ why }: { why?: string }) => (
+
+ not reported
+
+);
+
+function Tile({
+ label,
+ icon,
+ tone,
+ value,
+ sub,
+ action,
+}: {
+ label: string;
+ icon: IconName;
+ tone?: 'ok' | 'err' | 'warn';
+ value: ReactNode;
+ sub?: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+ {sub && {sub} }
+ {action && {action} }
+
+ );
+}
+
+/** Counts as a labelled bar list: every value is printed, the bar only helps compare. */
+function Bars({ rows, label, iconOf, nameOf, onPick }: { rows: CountRow[]; label: string; iconOf?: (k: string) => IconName; nameOf: (k: string) => string; onPick?: (k: string) => void }) {
+ const max = Math.max(1, ...rows.map((r) => r.count));
+ return (
+
+ {rows.map((r) => {
+ const body = (
+ <>
+
+ {iconOf && }
+ {nameOf(r.key)}
+
+
+
+
+ {r.count}
+ 0 || undefined}>
+ {r.errors > 0 ? (
+ <>
+
+ {r.errors} failed
+ >
+ ) : (
+ 'all ok'
+ )}
+
+ >
+ );
+ const title = `${nameOf(r.key)}: ${r.count} call${r.count === 1 ? '' : 's'}, ${r.errors} failed`;
+ return (
+
+ {onPick ? (
+ onPick(r.key)}>
+ {body}
+
+ ) : (
+
+ {body}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Run metrics (D8): duration, tool calls by category, failures, tests,
+ * tokens and model per agent where reported, permission waits. Every value
+ * comes from recorded events (state/metrics.ts); what the source did not
+ * report reads "not reported", never 0.
+ */
+export default function RunMetrics({ variant = 'full', onShowTimeline }: { variant?: 'full' | 'compact'; onShowTimeline?: () => void }) {
+ const store = useStudioStore();
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const conn = useStudio((s) => s.conn);
+ const runId = useStudio((s) => s.runId);
+ const world = useStudio((s) => s.world);
+ const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+ const m: Metrics = useMemo(() => computeRunMetrics(exec, store.getState().events()), [exec, store]);
+ const s = store.getState();
+ const compact = variant === 'compact';
+
+ if (!runId) {
+ return (
+
+ Open a run to see its metrics.
+
+ );
+ }
+ if (lastSequence === 0) {
+ if (conn === 'loading') return ;
+ return (
+
+ Metrics appear with the run’s first events.
+
+ );
+ }
+
+ const pickCategory = (k: string) => {
+ s.setFilter({ ...EMPTY_FILTER, categories: [k] });
+ onShowTimeline?.();
+ };
+ const pickTool = (k: string) => {
+ s.setFilter({ ...EMPTY_FILTER, tools: [k] });
+ onShowTimeline?.();
+ };
+ const claudeCode = exec.run.source?.kind === 'claude-code';
+ const c = m.calls;
+ const callParts = [
+ `${c.success} ok`,
+ c.error ? `${c.error} error` : null,
+ c.couldNotRun ? `${c.couldNotRun} could not run` : null,
+ c.running ? `${c.running} running` : null,
+ c.inferred ? `${c.inferred} inferred` : null,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ const p = m.permissions;
+ const tests = m.tests;
+
+ return (
+
+
+ Totals for the whole run{m.duration?.running ? ' so far' : ''}, from {formatInt(m.events)} recorded events. Values the source did not report say “not reported”, never 0.
+
+
+
+ }
+ sub={m.duration?.running ? 'to the last event · still recording' : m.duration ? 'start to end' : undefined}
+ />
+
+ 0 ? 'err' : undefined}
+ value={m.failures.total}
+ sub={
+ m.failures.total === 0
+ ? 'none recorded'
+ : m.failures.open > 0
+ ? `${m.failures.open} still open`
+ : m.failures.resolvedByLaterSuccess === m.failures.total
+ ? 'each followed by a successful retry (inferred)'
+ : m.failures.resolvedByLaterSuccess > 0
+ ? 'all resolved (some inferred from a later success)'
+ : 'all in tasks that later finished'
+ }
+ action={
+ m.failures.firstSeq !== null ? (
+ s.selectEvent(m.failures.firstSeq!, { seek: true })}>
+ Jump to the first
+
+ ) : undefined
+ }
+ />
+ 0 ? 'err' : 'ok') : undefined}
+ value={
+ tests ? (
+
+ 0 ? 'x' : 'check'} size={14} />
+ {tests.latest.passed}/{tests.latest.tests}
+
+ ) : (
+
+ )
+ }
+ sub={
+ tests
+ ? `${tests.latest.failed > 0 ? `${tests.latest.failed} failing` : 'passed'} in the latest of ${tests.runs} test run${tests.runs === 1 ? '' : 's'}${tests.failingRuns ? ` · ${tests.failingRuns} with failures` : ''}`
+ : 'no test counts in any result'
+ }
+ action={
+ tests ? (
+ s.selectEvent(tests.latest.seq, { seek: true })}>
+ Open the latest run
+
+ ) : undefined
+ }
+ />
+ }
+ sub={
+ m.usage.reportedTotal !== null
+ ? `reported for ${m.usage.reportedAgents} of ${m.usage.agents.length} agents`
+ : m.usage.agents.length
+ ? 'no agent reported usage'
+ : undefined
+ }
+ />
+ } sub="no source reports cost" />
+ 0 ? 'warn' : undefined}
+ value={p.requested}
+ sub={
+ p.requested === 0
+ ? 'none requested'
+ : [p.approved ? `${p.approved} approved` : null, p.denied ? `${p.denied} denied` : null, p.pending ? `${p.pending} waiting` : null, p.inferred ? `${p.inferred} inferred` : null]
+ .filter(Boolean)
+ .join(' · ')
+ }
+ />
+ {!compact && (
+ : '—'}
+ sub={
+ p.waits
+ ? `${p.waits.count} answered · longest ${formatDuration(p.waits.longestMs)}`
+ : p.inferred
+ ? 'answers were inferred, so no real wait time'
+ : p.requested
+ ? 'no answer recorded yet'
+ : 'none requested'
+ }
+ />
+ )}
+ {m.turns !== null && }
+
+
+
+
+ Tool calls by category
+
+ {c.byCategory.length > 0 ? (
+ <>
+ CATEGORY_ICON[k as ToolCategory] ?? 'dot'} nameOf={categoryLabel} onPick={pickCategory} />
+ {c.categorised < c.total && {c.total - c.categorised} calls have no category.
}
+ >
+ ) : (
+ {c.total ? 'This run’s source does not categorise its tool calls; see the calls by tool below.' : 'No tool calls recorded.'}
+ )}
+
+
+ {(!compact || c.byCategory.length === 0) && c.byTool.length > 0 && (
+
+
+ k} onPick={pickTool} />
+ {c.byTool.length > 10 && {c.byTool.length - 10} more tools.
}
+
+ )}
+
+
+
+ Tokens and model per agent
+
+ {m.usage.agents.length === 0 ? (
+ No agents registered yet.
+ ) : (
+
+
+
+
+ Agent
+ Model
+
+ Tokens
+
+ {!compact && Breakdown }
+ {!compact && Reported by }
+
+
+
+ {m.usage.agents.map((a) => {
+ const look = agentLook(exec, world, a.agentId);
+ return (
+
+
+ s.selectAgent(a.agentId)}>
+
+ {a.name}
+
+
+ {a.model ? {a.model} : }
+ {a.totalTokens !== null ? {formatTokens(a.totalTokens)} : }
+ {!compact && {a.usage ? usageParts(a.usage) : '—'} }
+ {!compact && (
+
+ {a.reportedBy ? (
+
+ {claudeCode ? 'Claude Code' : 'reported'}
+
+ ) : (
+ '—'
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+ )}
+ {claudeCode && m.usage.agents.some((a) => a.totalTokens === null) && (
+ Claude Code reports usage for subagents only, in the Agent tool result. Its hooks do not report the main agent’s tokens.
+ )}
+
+
+ );
+}
diff --git a/apps/studio/src/components/RunStates.module.css b/apps/studio/src/components/RunStates.module.css
new file mode 100644
index 0000000..3d519b9
--- /dev/null
+++ b/apps/studio/src/components/RunStates.module.css
@@ -0,0 +1,27 @@
+.center {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 0 var(--rl-space-4) var(--rl-space-4);
+ border-radius: var(--rl-radius-hero);
+ background: var(--rl-surface);
+}
+.hint {
+ margin-top: var(--rl-space-2);
+ font-size: var(--rl-text-xs);
+}
+.banner {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-2) var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: color-mix(in srgb, var(--rl-failure) 12%, transparent);
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+}
+.banner svg {
+ flex: none;
+ color: var(--rl-failure);
+}
diff --git a/apps/studio/src/components/RunStates.tsx b/apps/studio/src/components/RunStates.tsx
new file mode 100644
index 0000000..37f8968
--- /dev/null
+++ b/apps/studio/src/components/RunStates.tsx
@@ -0,0 +1,116 @@
+'use client';
+import { useEffect, useState, type ReactNode } from 'react';
+import { apiBase, DEFAULT_ALLOWED_ORIGINS, startRuntimeCommand } from '../lib/config';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES } from '../state/deeplink';
+import { runNumber } from '../state/format';
+import { Button, ButtonLink } from '../ui/Button';
+import { Icon } from '../ui/Icon';
+import { EmptyState, ErrorState, SkeletonRows } from '../ui/States';
+import styles from './RunStates.module.css';
+import { useStartDemo } from './useStartDemo';
+
+const SAVED = 'Recorded runs stay in the runtime’s database (.runloop/runloop.db by default); nothing is lost while it is stopped.';
+
+/** The origin this page is served from, when the runtime would not accept it by default (CORS). */
+function useForeignOrigin(): string | null {
+ const [origin, setOrigin] = useState(null);
+ useEffect(() => {
+ const o = window.location.origin;
+ setOrigin(DEFAULT_ALLOWED_ORIGINS.includes(o) ? null : o);
+ }, []);
+ return origin;
+}
+
+/** The runtime does not answer: what failed, that data is safe, and the exact command to fix it. */
+export function OfflineState({ compact }: { compact?: boolean }) {
+ const origin = useForeignOrigin();
+ useStudio((s) => s.apiBaseOverride); // re-render when the runtime URL changes
+ const url = apiBase();
+ return (
+
+
+ No runtime answered at {url}. Start it from the repo root; the Studio reconnects on its own within a few seconds.
+
+ {origin && (
+
+ Already running? It may not accept this page’s origin. Restart it with RUNLOOP_ALLOWED_ORIGINS={origin}.
+
+ )}
+
+ );
+}
+
+/** A slim banner on run pages when the runtime drops while a run is open. */
+export function OfflineBanner() {
+ const status = useStudio((s) => s.runtime.status);
+ const conn = useStudio((s) => s.conn);
+ if (status !== 'offline' && conn !== 'error') return null;
+ return (
+
+
+
+ {status === 'offline' ? 'Runtime offline.' : 'Stream disconnected.'} Showing the events already received; they are saved. Start the runtime with{' '}
+ {startRuntimeCommand()} and the Studio reconnects.
+
+
+ );
+}
+
+export function NoRunsState() {
+ const demo = useStartDemo();
+ return (
+
+ void demo.start()} busy={demo.starting} disabled={demo.disabledReason !== null}>
+ Run demo mission
+
+
+ Connect Claude Code
+
+ >
+ }
+ >
+ Start the scripted demo to watch agents edit files and run tests for real, or connect Claude Code to trace your own sessions.
+
+ );
+}
+
+function RunErrorState() {
+ const store = useStudioStore();
+ const loadError = useStudio((s) => s.loadError);
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const n = runNumber(runs, runId);
+ return (
+ store.getState().retryConnection()}
+ >
+ {loadError}
+
+ );
+}
+
+/** Run pages render their content only once there is a run to show; otherwise the right state. */
+export function RunGate({ children }: { children: ReactNode }) {
+ const runtime = useStudio((s) => s.runtime.status);
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const loadError = useStudio((s) => s.loadError);
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ let state: ReactNode = null;
+ if (!mounted || (runtime === 'unknown' && !runId)) state = ;
+ else if (runtime === 'offline' && (!runId || loadError)) state = ;
+ else if (loadError) state = ;
+ else if (!runId && runs.length === 0) state = ;
+ else if (!runId) state = ;
+ if (state) return {state}
;
+ return <>{children}>;
+}
diff --git a/apps/studio/src/components/SourceFacts.module.css b/apps/studio/src/components/SourceFacts.module.css
new file mode 100644
index 0000000..951ebda
--- /dev/null
+++ b/apps/studio/src/components/SourceFacts.module.css
@@ -0,0 +1,35 @@
+.dl {
+ display: flex;
+ flex-direction: column;
+}
+.row {
+ display: grid;
+ grid-template-columns: minmax(110px, 38%) minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ padding: 6px 0;
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+ font-size: var(--rl-text-xs);
+}
+.row:last-child {
+ box-shadow: none;
+}
+.row dt {
+ color: var(--rl-text-muted);
+}
+.row dd {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.none {
+ color: var(--rl-text-faint);
+ font-style: italic;
+}
+.mono {
+ font-family: var(--rl-font-mono);
+}
+.link {
+ color: var(--rl-text);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ border-radius: 4px;
+}
diff --git a/apps/studio/src/components/SourceFacts.tsx b/apps/studio/src/components/SourceFacts.tsx
new file mode 100644
index 0000000..343b4f5
--- /dev/null
+++ b/apps/studio/src/components/SourceFacts.tsx
@@ -0,0 +1,75 @@
+'use client';
+import type { ExecState } from '@runloop-world/core';
+import type { RunSummary, TraceSource } from '@runloop-world/events';
+import type { ReactNode } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { selectRun } from '../state/hooks';
+import { runNumber } from '../state/format';
+import { shortId } from '../state/runs';
+import styles from './SourceFacts.module.css';
+
+const SOURCE_NAME: Record = { 'claude-code': 'Claude Code' };
+
+function Row({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children ?? not reported }
+
+ );
+}
+
+/** The traced run's source, from its run.started (full) or the run summary (partial). */
+export function traceSourceOf(run: RunSummary | null, exec: ExecState): Partial | null {
+ return exec.run.source ?? run?.source ?? null;
+}
+
+/**
+ * Where a traced run came from (plan §7.3): session (short id), segment,
+ * project, model if reported, permission mode and plugin version. Values the
+ * source did not report say so.
+ */
+export default function SourceFacts({ run, exec }: { run: RunSummary | null; exec: ExecState }) {
+ const store = useStudioStore();
+ const runs = useStudio((s) => s.runs);
+ const src = traceSourceOf(run, exec);
+ if (!src) return null;
+ const continues = src.continuesRunId ?? null;
+ const prevN = continues ? runNumber(runs, continues) : null;
+
+ return (
+
+ {src.kind ? (SOURCE_NAME[src.kind] ?? src.kind) : null}
+ {src.projectLabel ? {src.projectLabel} : null}
+
+ {src.sessionId ? (
+
+ {shortId(src.sessionId)}
+
+ ) : null}
+
+
+ {src.segment !== undefined ? (
+
+ {src.segment}
+ {continues &&
+ (prevN !== null ? (
+ <>
+ {' · continues '}
+ selectRun(store, continues)}>
+ run #{prevN}
+
+ >
+ ) : (
+ ' · continues an earlier run'
+ ))}
+
+ ) : null}
+
+ {src.model ? {src.model} : null}
+ {src.permissionMode ? {src.permissionMode} : null}
+ {src.startSource ?? null}
+ {src.pluginVersion ? {src.pluginVersion} : null}
+
+ );
+}
diff --git a/apps/studio/src/components/StatePanel.tsx b/apps/studio/src/components/StatePanel.tsx
new file mode 100644
index 0000000..07ae122
--- /dev/null
+++ b/apps/studio/src/components/StatePanel.tsx
@@ -0,0 +1,51 @@
+'use client';
+import { formatClock } from '@runloop-world/core';
+import { useMemo, useState } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { Chip } from '../ui/Chip';
+import styles from './InfoPanels.module.css';
+
+/** Raw Layer-1 (execution) and Layer-2 (world) state at the playhead. */
+export default function StatePanel() {
+ const store = useStudioStore();
+ const seq = useStudio((s) => s.playheadSeq);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const [open, setOpen] = useState<'exec' | 'world'>('exec');
+ const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]);
+ const json = useMemo(() => JSON.stringify(open === 'exec' ? state.exec : state.world, null, 2), [state, open]);
+
+ return (
+
+
+ State after event #{seq} at {formatClock(store.getState().timeOfSeq(seq))}. Move the playhead to inspect another moment.
+
+ {state.exec.anomalies.length > 0 && (
+
+
+
+ {state.exec.anomalies.length} anomal{state.exec.anomalies.length === 1 ? 'y' : 'ies'}
+
+
+
+ {state.exec.anomalies.map((a, i) => (
+
+ store.getState().selectEvent(a.seq)}>
+ #{a.seq} {a.code.replace(/_/g, ' ')} {a.detail}
+
+
+ ))}
+
+
+ )}
+
+ setOpen('exec')}>
+ Execution (Layer 1)
+
+ setOpen('world')}>
+ World (Layer 2)
+
+
+
{json}
+
+ );
+}
diff --git a/apps/studio/src/components/StudioSidebar.module.css b/apps/studio/src/components/StudioSidebar.module.css
new file mode 100644
index 0000000..cd4d486
--- /dev/null
+++ b/apps/studio/src/components/StudioSidebar.module.css
@@ -0,0 +1,157 @@
+.wrap {
+ height: 100%;
+}
+.runs + .runs {
+ margin-top: var(--rl-space-4);
+}
+.heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 var(--rl-space-3) var(--rl-space-2);
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.run {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ column-gap: var(--rl-space-2);
+ width: 100%;
+ padding: 6px var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ text-align: left;
+ transition: background-color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.run:hover {
+ background: var(--rl-surface);
+}
+.run[aria-current='true'] {
+ background: var(--rl-surface);
+ box-shadow: inset 2px 0 0 var(--rl-accent-text);
+}
+.runStatus {
+ grid-row: 1 / span 2;
+ display: grid;
+ place-items: center;
+ height: 18px;
+ color: var(--rl-text-muted);
+}
+.runStatus[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.runStatus[data-tone='err'] {
+ color: var(--rl-failure);
+}
+.runStatus[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.runStatus[data-tone='live'] {
+ color: var(--rl-accent-text);
+}
+.runTitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+ color: var(--rl-text);
+}
+.runMeta {
+ grid-column: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+}
+.runMeta svg {
+ flex: none;
+}
+.liveDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--rl-accent-text);
+}
+.more {
+ display: block;
+ padding: 6px var(--rl-space-3);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.more:hover {
+ color: var(--rl-text);
+}
+.empty {
+ padding: 0 var(--rl-space-3);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-2);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface);
+}
+.runtime {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ min-width: 0;
+ flex: 1;
+}
+.runtimeDot {
+ width: 8px;
+ height: 8px;
+ flex: none;
+ margin: 0 4px;
+ border-radius: 50%;
+ background: var(--rl-text-faint);
+}
+.runtimeDot[data-status='online'] {
+ background: var(--rl-success);
+}
+.runtimeDot[data-status='offline'] {
+ background: var(--rl-failure);
+}
+.runtimeText {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.runtimeLabel {
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+ white-space: nowrap;
+}
+.runtimeHost {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.wrap[data-rail] .footer {
+ flex-direction: column;
+ padding: var(--rl-space-2) 0;
+ background: transparent;
+}
+.wrap[data-rail] .runtimeText {
+ display: none;
+}
diff --git a/apps/studio/src/components/StudioSidebar.tsx b/apps/studio/src/components/StudioSidebar.tsx
new file mode 100644
index 0000000..e4d510f
--- /dev/null
+++ b/apps/studio/src/components/StudioSidebar.tsx
@@ -0,0 +1,132 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { apiBase } from '../lib/config';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES } from '../state/deeplink';
+import { areaOf, formatWhen, isRunPage } from '../state/format';
+import { selectRun, useTicker } from '../state/hooks';
+import { activityText, groupRuns, isTracedRun, runSourceLabel, shortId } from '../state/runs';
+import { runStatusView } from '../state/view';
+import type { RunSummary } from '@runloop-world/events';
+import { Button } from '../ui/Button';
+import { GlyphIcon, Icon } from '../ui/Icon';
+import { Sidebar, type NavItem } from '../ui/Sidebar';
+import styles from './StudioSidebar.module.css';
+
+/** Areas that work today; Experiments and Evaluations stay hidden until they do. */
+const AREAS = [
+ { href: ROUTES.overview, label: 'Overview', icon: 'overview' },
+ { href: ROUTES.world, label: 'Live World', icon: 'world' },
+ { href: ROUTES.traces, label: 'Traces', icon: 'traces' },
+ { href: ROUTES.integrations, label: 'Integrations', icon: 'integrations' },
+ { href: ROUTES.settings, label: 'Settings', icon: 'settings' },
+] as const;
+
+const RECENT = 8;
+
+export default function StudioSidebar({ pathname, rail, onNavigate }: { pathname: string; rail: boolean; onNavigate?: () => void }) {
+ const store = useStudioStore();
+ const router = useRouter();
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const runtime = useStudio((s) => s.runtime);
+ const themePref = useStudio((s) => s.themePref);
+ const area = areaOf(pathname);
+
+ const items: NavItem[] = AREAS.map((a) => ({ ...a, active: area === a.href }));
+
+ const open = (id: string) => {
+ selectRun(store, id);
+ if (!isRunPage(pathname)) router.push(ROUTES.world);
+ onNavigate?.();
+ };
+
+ const groups = groupRuns(runs);
+ const recent = runs.filter((r) => r.status !== 'running').slice(0, RECENT);
+ useTicker(groups.live.length > 0, 15_000);
+ const now = Date.now();
+
+ const row = (r: RunSummary) => {
+ const traced = isTracedRun(r);
+ const view = runStatusView(r.status, r.paused, traced);
+ const n = runs.length - runs.indexOf(r);
+ const activity = r.status === 'running' && traced ? activityText(r, now) : null;
+ const meta = r.source ? `session ${shortId(r.source.sessionId)}` : `#${n}`;
+ return (
+
+ open(r.runId)}>
+
+
+ {view.text}
+
+ {r.title}
+
+
+ {meta} · {activity ?? formatWhen(r.createdAt)}
+
+
+
+ );
+ };
+
+ const nextTheme = themePref === 'dark' ? 'light' : themePref === 'light' ? 'system' : 'dark';
+ const host = apiBase().replace(/^https?:\/\//, '');
+
+ return (
+
+
+
+
+
+
+ {runtime.status === 'online' ? 'Runtime online' : runtime.status === 'offline' ? 'Runtime offline' : 'Checking runtime…'}
+
+ {host}
+
+
+ store.getState().setThemePref(nextTheme)}
+ />
+
+ }
+ >
+ {!rail && groups.live.length > 0 && (
+
+
+ {groups.live.map(row)}
+
+ )}
+ {!rail && recent.length > 0 && (
+
+ )}
+ {!rail && runtime.status === 'online' && runs.length === 0 && (
+ No runs yet. Start the demo mission or connect Claude Code from Overview.
+ )}
+
+
+ );
+}
diff --git a/apps/studio/src/components/TimelinePanel.module.css b/apps/studio/src/components/TimelinePanel.module.css
new file mode 100644
index 0000000..5aaf3fb
--- /dev/null
+++ b/apps/studio/src/components/TimelinePanel.module.css
@@ -0,0 +1,235 @@
+.list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--rl-space-1) var(--rl-space-2) var(--rl-space-3);
+}
+.item {
+ position: relative;
+ border-radius: var(--rl-radius-control);
+}
+.spacer {
+ list-style: none;
+}
+/*
+ * After the playhead: quieter text that still reads at AA (no opacity), and a
+ * line at the playhead itself, so "not yet happened" is not colour alone.
+ */
+.item[data-future] .title,
+.item[data-future] .tool {
+ color: var(--rl-text-muted);
+}
+.item[data-future] .meta,
+.item[data-future] .time,
+.item[data-future] .duration {
+ color: var(--rl-text-faint);
+}
+.item[data-selected] .time,
+.item[data-selected] .meta,
+.item[data-selected] .duration {
+ color: var(--rl-text-muted);
+}
+.item[data-playhead]::before {
+ content: '';
+ position: absolute;
+ left: var(--rl-space-2);
+ right: var(--rl-space-2);
+ top: -1px;
+ border-top: 1px dashed var(--rl-line-strong);
+}
+.hidden {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ margin: 0 var(--rl-space-3) var(--rl-space-1);
+ padding: 2px var(--rl-space-1) 2px var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.row {
+ display: grid;
+ grid-template-columns: 44px 18px minmax(0, 1fr) auto;
+ align-items: start;
+ column-gap: var(--rl-space-2);
+ width: 100%;
+ padding: 7px var(--rl-space-2);
+ border-radius: var(--rl-radius-control);
+ text-align: left;
+ /* No transition: the playhead and selection markers follow the data instantly. */
+}
+.row:hover {
+ background: var(--rl-surface-2);
+}
+.row:hover .time {
+ color: var(--rl-text-muted);
+}
+/*
+ * Playhead row: a quiet tonal marker. Selected row: a neutral tonal surface
+ * with the lime only in the 2 px bar, so chips and muted text on it keep AA.
+ */
+.item[data-current] .row {
+ background: color-mix(in srgb, var(--rl-surface-2) 70%, transparent);
+}
+.item[data-current] .time {
+ color: var(--rl-text);
+}
+.item[data-selected] .row {
+ background: var(--rl-surface-2);
+ box-shadow: inset 2px 0 0 var(--rl-accent-text);
+}
+.time {
+ padding-top: 1px;
+ color: var(--rl-text-faint);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+ line-height: 18px;
+}
+.icon {
+ display: grid;
+ place-items: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ color: var(--rl-text-muted);
+ background: var(--rl-surface-2);
+}
+.icon[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.icon[data-tone='err'] {
+ color: var(--rl-failure);
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+}
+.icon[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.icon[data-tone='live'] {
+ color: var(--rl-accent-text);
+ background: var(--rl-accent-soft);
+}
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--rl-text);
+ font-size: var(--rl-text-sm);
+ line-height: 18px;
+}
+.tool {
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.meta {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ min-width: 0;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.actor {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex: none;
+ white-space: nowrap;
+}
+.detail {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail::before {
+ content: '·';
+ margin-right: var(--rl-space-2);
+ color: var(--rl-text-faint);
+}
+.facts {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-1);
+ padding-top: 1px;
+}
+.duration {
+ min-width: 44px;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+ text-align: right;
+}
+.category {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex: none;
+}
+.category::before {
+ content: '·';
+ margin-right: 2px;
+ color: var(--rl-text-faint);
+}
+.delegate {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: var(--rl-radius-sm);
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+ font-size: var(--rl-text-2xs);
+ font-weight: 500;
+}
+.delegate:hover {
+ background: var(--rl-surface-3);
+}
+.artifacts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-1);
+ padding: 0 var(--rl-space-2) 6px calc(44px + 18px + var(--rl-space-2) * 3);
+}
+.artifact {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: var(--rl-radius-sm);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.artifact:hover {
+ color: var(--rl-text);
+ background: var(--rl-surface-3);
+}
+
+@media (max-width: 760px) {
+ .row {
+ grid-template-columns: 38px 18px minmax(0, 1fr);
+ }
+ .facts {
+ grid-column: 3;
+ flex-wrap: wrap;
+ padding-top: 4px;
+ }
+ .duration {
+ text-align: left;
+ min-width: 0;
+ }
+ .artifacts {
+ padding-left: calc(38px + 18px + var(--rl-space-2) * 3);
+ }
+}
diff --git a/apps/studio/src/components/TimelinePanel.tsx b/apps/studio/src/components/TimelinePanel.tsx
new file mode 100644
index 0000000..18fdccd
--- /dev/null
+++ b/apps/studio/src/components/TimelinePanel.tsx
@@ -0,0 +1,218 @@
+'use client';
+import { formatClock } from '@runloop-world/core';
+import { useEffect, useMemo, useRef } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { activeFilterCount, EMPTY_FILTER } from '../state/filters';
+import { formatDuration, lastRowAtOrBefore, rowIndexBySeq, rowStatusAt, selectedRow, STATUS_LABEL, type RowStatus, type TimelineRow } from '../state/timeline';
+import { CATEGORY_ICON, categoryLabel } from '../state/labels';
+import { agentLook } from '../state/view';
+import { Button } from '../ui/Button';
+import { AgentDot, Chip } from '../ui/Chip';
+import { GlyphIcon, Icon } from '../ui/Icon';
+import { EmptyState, SkeletonRows } from '../ui/States';
+import { listKeyHandler, useRovingRow, useVirtualRows } from '../ui/useVirtualRows';
+import styles from './TimelinePanel.module.css';
+import type { TraceModel } from './useTraceModel';
+
+const STATUS_ICON: Partial> = { running: '⚙', ok: '✓', error: '✗', warn: '⚠' };
+const STATUS_TONE: Record = { running: 'live', ok: 'ok', error: 'err', warn: 'warn', info: 'muted' };
+
+
+/**
+ * The run as readable rows (a tool call's start and result are one row).
+ * Selecting a row selects its event everywhere (world, inspector, URL) and
+ * moves the playhead there.
+ */
+export default function TimelinePanel({ model }: { model: TraceModel }) {
+ const store = useStudioStore();
+ const playheadSeq = useStudio((s) => s.playheadSeq);
+ const selectedSeq = useStudio((s) => s.selectedSeq);
+ const world = useStudio((s) => s.world);
+ const conn = useStudio((s) => s.conn);
+ const ended = useStudio((s) => s.ended);
+ const filter = useStudio((s) => s.filter);
+ const follow = useStudio((s) => s.cursor.mode === 'live' || s.cursor.playing);
+ const listRef = useRef(null);
+ const { rows, visibleRows, exec } = model;
+ const keys = useMemo(() => visibleRows.map((r) => r.key), [visibleRows]);
+ const v = useVirtualRows(listRef, keys, 50);
+ const current = lastRowAtOrBefore(visibleRows, playheadSeq);
+ const selectedAgentId = useStudio((s) => s.selectedAgentId);
+ const rowOfSeq = useMemo(() => rowIndexBySeq(rows), [rows]);
+ const { index: selected, hiddenSeq: hiddenBySeq } = selectedRow(rows, rowOfSeq, visibleRows, selectedSeq, selectedAgentId);
+ // One Tab stop for the whole list: the focused row, else the selected one, else the row at the playhead.
+ const roving = useRovingRow(selected >= 0 ? selected : Math.max(0, current));
+ const tabStop = Math.min(roving.tabStop, visibleRows.length - 1);
+
+ useEffect(() => {
+ if (selected >= 0) v.scrollToIndex(selected);
+ }, [selected]);
+
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list || current < 0) return;
+ if (follow) {
+ v.scrollToIndex(current);
+ return;
+ }
+ // A seek while paused: show the row if it is off-screen; a click on a visible row does not scroll.
+ const row = list.querySelector('[data-current="true"]');
+ if (!row) {
+ v.scrollToIndex(current, 'center');
+ return;
+ }
+ const l = list.getBoundingClientRect();
+ const r = row.getBoundingClientRect();
+ if (r.top < l.top || r.bottom > l.bottom) v.scrollToIndex(current, 'center');
+ }, [current, follow]);
+
+ const onRow = (row: TimelineRow) => store.getState().selectEvent(row.endSeq ?? row.seq, { seek: true });
+
+ if (rows.length === 0) {
+ if (conn === 'loading') return ;
+ return (
+
+ {ended ? Nothing to replay here.
: Events appear here the moment the runtime records them.
}
+
+ );
+ }
+ if (visibleRows.length === 0) {
+ return (
+ store.getState().setFilter(EMPTY_FILTER)}>
+ Clear {activeFilterCount(filter) > 1 ? 'all filters' : 'the filter'}
+
+ }
+ >
+ {rows.length} events are hidden by the current search and filters.
+
+ );
+ }
+
+ return (
+ <>
+ {hiddenBySeq !== null && (
+
+
+ Selected event #{hiddenBySeq} is hidden by the filters.
+ store.getState().setFilter(EMPTY_FILTER)}>
+ Show it
+
+
+ )}
+
+ {v.padTop > 0 && }
+ {visibleRows.slice(v.start, v.end).map((row, k) => {
+ const i = v.start + k;
+ const status = rowStatusAt(row, playheadSeq);
+ const look = agentLook(exec, world, row.actorId);
+ const future = row.seq > playheadSeq;
+ const icon = row.callId !== null ? (STATUS_ICON[status] ?? row.icon) : row.icon;
+ const isSelected = i === selected;
+ const finished = status !== 'running';
+ const tabIndex = i === tabStop ? 0 : -1;
+ return (
+
+ roving.onFocusRow(i)}
+ onClick={() => onRow(row)}
+ aria-current={isSelected ? 'true' : undefined}
+ aria-label={`${formatClock(row.tMs)} ${look.name}: ${row.title}${status !== 'info' ? `, ${STATUS_LABEL[status]}` : ''}${future ? ', after the playhead' : ''}`}
+ >
+ {formatClock(row.tMs)}
+
+
+
+
+
+ {row.tool ? (
+ <>
+ {row.tool} {row.title.slice(row.tool.length).trim()}
+ >
+ ) : (
+ row.title
+ )}
+
+
+
+
+ {look.name}
+
+ {row.category && (
+
+
+ {categoryLabel(row.category)}
+
+ )}
+ {row.detail && (finished || row.callId === null) && {row.detail} }
+
+
+
+ {status === 'error' && error }
+ {status === 'running' && running }
+ {row.inferredSeq !== null && row.inferredSeq <= playheadSeq && (
+
+ inferred
+
+ )}
+ {row.metrics && finished && (
+ 0 ? 'err' : 'ok'}>
+ {row.metrics.passed}/{row.metrics.tests} passed
+
+ )}
+ {row.durationMs !== null && finished && {formatDuration(row.durationMs)} }
+
+
+ {row.delegateId && (
+
+ store.getState().selectAgent(row.delegateId)}
+ aria-label={`Select subagent ${agentLook(exec, world, row.delegateId).name}`}
+ >
+
+
+ {agentLook(exec, world, row.delegateId).name}
+
+
+ )}
+ {row.artifactIds.length > 0 && (row.endSeq === null || row.endSeq <= playheadSeq) && (
+
+ {row.artifactIds.map((id) => (
+ store.getState().openArtifact(id)}>
+
+ {exec.artifacts[id]?.name ?? id}
+
+ ))}
+
+ )}
+
+ );
+ })}
+ {v.padBottom > 0 && }
+
+ >
+ );
+}
diff --git a/apps/studio/src/components/WorldStage.module.css b/apps/studio/src/components/WorldStage.module.css
new file mode 100644
index 0000000..843353d
--- /dev/null
+++ b/apps/studio/src/components/WorldStage.module.css
@@ -0,0 +1,126 @@
+.stage {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-world-bg);
+ overflow: hidden;
+}
+.stage[data-view='graph'] {
+ background: var(--rl-surface);
+}
+.canvas {
+ position: absolute;
+ inset: 0;
+}
+.loading {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+}
+.message {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-6);
+ text-align: center;
+ color: var(--rl-text-muted);
+}
+.message p {
+ max-width: 460px;
+}
+.messageTitle {
+ color: var(--rl-text);
+ font-size: var(--rl-text-md);
+ font-weight: 600;
+}
+.message[data-tone='err'] .messageTitle {
+ color: var(--rl-failure);
+}
+.messageActions {
+ display: flex;
+ gap: var(--rl-space-2);
+ margin-top: var(--rl-space-2);
+}
+.toolbarLeft {
+ position: absolute;
+ top: var(--rl-space-3);
+ left: var(--rl-space-3);
+ z-index: 2;
+}
+.toolbar {
+ position: absolute;
+ top: var(--rl-space-3);
+ right: var(--rl-space-3);
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--rl-space-2);
+}
+/* Glass: floating controls over the 3D world only. */
+.glassButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 10px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-glass);
+ backdrop-filter: blur(12px) saturate(1.2);
+ box-shadow: 0 0 0 1px var(--rl-glass-line);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 500;
+}
+.glassButton:hover {
+ color: var(--rl-text);
+}
+.glassButton[aria-pressed='true'] {
+ color: var(--rl-text);
+ box-shadow:
+ 0 0 0 1px var(--rl-glass-line),
+ inset 0 -2px 0 var(--rl-accent-text);
+}
+
+.roster {
+ position: absolute;
+ left: var(--rl-space-3);
+ bottom: var(--rl-space-3);
+ z-index: 2;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-1);
+ max-width: calc(100% - 24px);
+}
+.character {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 30px;
+ padding: 0 6px 0 10px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-glass);
+ backdrop-filter: blur(12px) saturate(1.2);
+ box-shadow: 0 0 0 1px var(--rl-glass-line);
+ font-size: var(--rl-text-xs);
+}
+.character:hover {
+ box-shadow: 0 0 0 1px var(--rl-line-strong);
+}
+.character[aria-pressed='true'] {
+ box-shadow:
+ 0 0 0 1px var(--rl-accent-text),
+ 0 0 0 3px var(--rl-accent-soft);
+}
+.characterName {
+ font-weight: 600;
+ color: var(--rl-text);
+}
diff --git a/apps/studio/src/components/WorldStage.tsx b/apps/studio/src/components/WorldStage.tsx
new file mode 100644
index 0000000..6d3bd16
--- /dev/null
+++ b/apps/studio/src/components/WorldStage.tsx
@@ -0,0 +1,209 @@
+'use client';
+import dynamic from 'next/dynamic';
+import { Component, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { connectionState, connectionView } from '../state/connection';
+import { useStudio, useStudioStore } from '../state/context';
+import { SegmentedControl } from '../ui/SegmentedControl';
+import { Button } from '../ui/Button';
+import { Skeleton } from '../ui/States';
+import GraphView from '../views/graph/GraphView';
+import CharacterRoster from './CharacterRoster';
+import styles from './WorldStage.module.css';
+
+const WorldCanvas = dynamic(() => import('../scene/WorldCanvas'), {
+ ssr: false,
+ loading: () => ,
+});
+
+function webglAvailable(): boolean {
+ try {
+ const canvas = document.createElement('canvas');
+ return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
+ } catch {
+ return false;
+ }
+}
+
+function StageMessage({ title, children, tone }: { title: string; children?: ReactNode; tone?: 'err' | 'warn' }) {
+ return (
+
+ );
+}
+
+/**
+ * The Live World stage: the 3D world (scene internals are owned by src/scene)
+ * or, in the 2D slot, the Network view (agent graph or table; src/views/graph).
+ * Floating glass controls sit over the canvas; the roster is the keyboard route
+ * to every character.
+ */
+export default function WorldStage({ allow3d = true }: { allow3d?: boolean }) {
+ const world = useStudio((s) => s.world);
+ const runId = useStudio((s) => s.runId);
+ const getFrame = useStudio((s) => s.getFrame);
+ const subscribeEffects = useStudio((s) => s.subscribeEffects);
+ const selectedAgentId = useStudio((s) => s.selectedAgentId);
+ const selectedStationId = useStudio((s) => s.selectedStationId);
+ const selectedSeq = useStudio((s) => s.selectedSeq);
+ // The overlay chip says what the header says (one source: the connection state).
+ const conn = connectionView(useStudio((s) => connectionState(s)));
+ const store = useStudioStore();
+ const lastSequence = useStudio((s) => s.lastSequence);
+ // The scene only knows characters: pass a selected agent through only when it has one.
+ const embodiedSelected = useMemo(
+ () => selectedAgentId !== null && store.getState().stateAt(lastSequence).exec.agents[selectedAgentId]?.embodied === true,
+ [store, selectedAgentId, lastSequence],
+ );
+ const selectAgent = useStudio((s) => s.selectAgent);
+ const selectStation = useStudio((s) => s.selectStation);
+ const cameraMode = useStudio((s) => s.cameraMode);
+ const setCameraMode = useStudio((s) => s.setCameraMode);
+ const follow = useStudio((s) => s.follow);
+ const toggleFollow = useStudio((s) => s.toggleFollow);
+ const quality = useStudio((s) => s.quality);
+ const setQuality = useStudio((s) => s.setQuality);
+ const reducedMotion = useStudio((s) => s.reducedMotion);
+ const view = useStudio((s) => s.worldView);
+ const setView = useStudio((s) => s.setWorldView);
+
+ const [webgl, setWebgl] = useState<'checking' | 'ok' | 'unavailable'>('checking');
+ // The world whose canvas has rendered: a new run's world is not ready until its own canvas says so.
+ const [readyWorld, setReadyWorld] = useState(null);
+ useEffect(() => setWebgl(webglAvailable() ? 'ok' : 'unavailable'), []);
+ const onReady = useCallback(() => setReadyWorld(world), [world]);
+ // Without WebGL the Network view stands in for the world (same data, no 3D controls).
+ const noWebgl = view === 'world' && allow3d && webgl === 'unavailable';
+ const show3d = view === 'world' && allow3d && !noWebgl;
+ // Characters walk only in the 3D world: the store lets a finished run settle only while one is shown.
+ useEffect(() => (show3d ? store.getState().mountWorld() : undefined), [show3d, store]);
+
+ let body: ReactNode;
+ if (!world) {
+ body = (
+
+
+
+ );
+ } else if (!show3d) {
+ body = ;
+ } else if (webgl === 'ok') {
+ body = (
+ setView('graph')}>
+ store.getState().selectEvent(seq, opts)}
+ status={{ text: conn.text, live: conn.state === 'live', detail: conn.detail }}
+ />
+
+ );
+ }
+
+ return (
+
+ {body}
+ {world && runId && (
+ <>
+
+ {allow3d && (
+
+ )}
+
+ {show3d && (
+
+
+
+
+
+
+ Follow
+
+
setQuality(quality === 'high' ? 'low' : 'high')}
+ title="High quality adds glow; low is faster"
+ >
+ {quality === 'high' ? 'High quality' : 'Low quality'}
+
+
+ )}
+ {show3d && }
+ >
+ )}
+
+ );
+}
+
+/** If the scene throws, keep the rest of the Studio usable and offer the 2D route. */
+class SceneBoundary extends Component<{ children: ReactNode; onFallback: () => void }, { error: string | null }> {
+ override state = { error: null as string | null };
+
+ static getDerivedStateFromError(err: unknown) {
+ return { error: err instanceof Error ? err.message : String(err) };
+ }
+
+ override componentDidCatch(err: unknown) {
+ console.error('[studio] 3D scene failed', err);
+ }
+
+ override render() {
+ if (this.state.error) {
+ return (
+
+ {this.state.error}
+ No data was lost: the timeline, logs and inspector still show everything recorded.
+
+ this.setState({ error: null })}>
+ Reload the 3D view
+
+
+ Use the Network view
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/apps/studio/src/components/pages/IntegrationsPage.tsx b/apps/studio/src/components/pages/IntegrationsPage.tsx
new file mode 100644
index 0000000..a04edd3
--- /dev/null
+++ b/apps/studio/src/components/pages/IntegrationsPage.tsx
@@ -0,0 +1,299 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { CC, connectCommands, PLUGIN_DEFAULT_URL, pluginUrlPrefix } from '../../lib/claude-code';
+import { apiBase, normalizeApiBase } from '../../lib/config';
+import { useStudio, useStudioStore } from '../../state/context';
+import { ROUTES } from '../../state/deeplink';
+import { formatWhen } from '../../state/format';
+import { selectRun, useTicker } from '../../state/hooks';
+import { formatAgo, isTracedRun, lastTracedEventAt, runSubtitle } from '../../state/runs';
+import { Button } from '../../ui/Button';
+import { Card } from '../../ui/Card';
+import { Chip } from '../../ui/Chip';
+import { CommandSnippet } from '../../ui/CommandSnippet';
+import { Icon } from '../../ui/Icon';
+import { Skeleton } from '../../ui/States';
+import { OfflineState } from '../RunStates';
+import { useStartDemo } from '../useStartDemo';
+import styles from './Pages.module.css';
+
+/** From integrations/claude-code-plugin/README.md, "What is collected". */
+const COLLECTED = [
+ ['Sessions', 'start and end, the project folder’s name only (never its path), the permission mode'],
+ ['Prompts and final replies', 'stored as sensitive; RUNLOOP_TRACE_PROMPTS=0 stores a placeholder instead of your prompts'],
+ ['Tool calls', 'tool name and inputs (commands, project-relative paths, search patterns), success or failure'],
+ ['Tool results, summarised', 'sizes and counts (lines read, lines changed, files found) and the last 2 KB of command output, which the runtime reads for test counts such as “12 tests: 10 passed, 2 failed”'],
+ ['Subagents', 'start and stop, their own tool calls, and the model and token usage Claude Code reports for them'],
+ ['Permissions and markers', 'what was asked (a denial no hook reports is marked inferred), compaction, notifications, API errors'],
+] as const;
+
+const NOT_COLLECTED = [
+ 'File contents, diffs, or full command output',
+ 'Claude Code’s transcript files or their paths',
+ 'Absolute paths: they are made relative to the project, or shortened to ~/…',
+ 'Anything sent off this machine: the plugin posts to loopback addresses only',
+ 'The main agent’s token usage: Claude Code hooks do not report it',
+] as const;
+
+/** Integrations: connect Claude Code (real ingest status, install, doctor, what is collected, how to turn it off) and the scripted demo. */
+export default function IntegrationsPage() {
+ const runtime = useStudio((s) => s.runtime);
+ useStudio((s) => s.apiBaseOverride); // re-render when the runtime URL changes
+ const demo = useStartDemo();
+ const base = apiBase();
+ const cmds = connectCommands(base);
+ const customUrl = pluginUrlPrefix(base) !== '';
+
+ return (
+
+
+
+
+
}>
+
+
+ The plugin records each Claude Code session as a run: the main agent and every subagent become robots, tool calls go to stations, failing commands become incidents.
+ It never blocks, approves or changes what Claude Code does: hooks exit cleanly and print nothing.
+
+ {runtime.status === 'offline' ?
:
}
+
+
+
+
+ Start the Runloop runtime
+ From the Runloop repository root. On first start it writes the per-user ingest token the plugin reads.
+
+
+
+
+
+ Load the plugin in Claude Code
+ From your Runloop checkout (run it in the repository root):
+
+ Or install it from the marketplace, inside Claude Code:
+
+
+ or from a shell:
+
+
+
+ Install it once: with both the checkout and the marketplace copy, every event is sent twice.
+
+ {customUrl && (
+
+
+
+ This Studio reads the runtime at {normalizeApiBase(base)}, but the plugin posts to {PLUGIN_DEFAULT_URL} unless told otherwise, so these commands
+ set RUNLOOP_URL (or the plugin’s runtime_url option).
+
+
+ )}
+
+
+
+
+ Check the connection
+ Prints the runtime URL, whether the token file exists and the runtime answers, the spool size, the last accepted event and the last error, and sends anything still waiting in the spool.
+
+
+
+
+
+ Use Claude Code as normal
+ The session appears under Live sessions on the Overview with your first prompt, labelled Traced · Claude Code, and the Studio opens it when nothing else is open.
+
+
+
+
+
+
+
+
+
+
+ {COLLECTED.map(([what, detail]) => (
+
+
+
+ {what} : {detail}
+
+
+ ))}
+
+
Never collected:
+
+ {NOT_COLLECTED.map((what) => (
+
+
+ {what}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Local only. The runtime listens on loopback; the plugin refuses any other address.
+
+
+
+
+
+ Authenticated writes. A per-user token in ~/.config/runloop/ingest-token (mode 0600); requests from browser pages are rejected.
+
+
+
+
+
+ Secrets are masked twice , in the plugin and again in the runtime, before anything is stored.
+
+
+
+
+
+ Honest labels. Traced runs say Traced · Claude Code ; anything Runloop reconstructed says inferred ; values Claude Code does not report say not reported .
+
+
+
+
+
+ Reads are local and unauthenticated. Any process of yours can read traced runs from the runtime, as it can read its database file. Traced runs contain prompts and commands.
+
+
+
+
Turn it off
+
+
+
+
+ claude --bare and --safe-mode sessions skip plugins, so they are never traced. claude -p sessions are traced while the plugin is enabled; set RUNLOOP_TRACE=0 in CI.
+
+
+
+
+
+
+ Executed
+
+ }
+ >
+
+
+ A deterministic coding mission: scripted decisions, real tools. The runtime edits files and runs the test suite in a throwaway copy of a small repository, so you can see
+ Runloop end to end before connecting an agent. It is not a model; its runs are labelled Executed, never Traced.
+
+
+ void demo.start()} busy={demo.starting} disabled={demo.disabledReason !== null} title={demo.disabledReason ?? undefined}>
+ Run demo mission
+
+
+
+
+
+
+
+ );
+}
+
+function IngestChip() {
+ const runtime = useStudio((s) => s.runtime);
+ const ingest = runtime.ingest;
+ if (runtime.status === 'offline')
+ return (
+
+ runtime offline
+
+ );
+ if (runtime.status === 'unknown') return checking ;
+ if (ingest?.claudeCode)
+ return (
+
+ ingest ready
+
+ );
+ if (ingest) return ingest turned off ;
+ return (
+
+ no Claude Code ingest in this runtime
+
+ );
+}
+
+/** What the runtime reports right now: the ingest endpoint, live traced sessions, and the last event Claude Code sent. */
+function IngestStatus() {
+ const store = useStudioStore();
+ const router = useRouter();
+ const runtime = useStudio((s) => s.runtime);
+ const runs = useStudio((s) => s.runs);
+ const live = runs.filter((r) => r.status === 'running' && isTracedRun(r));
+ useTicker(live.length > 0, 15_000);
+ if (runtime.status === 'unknown') return ;
+ const ingest = runtime.ingest;
+ const last = lastTracedEventAt(runs);
+ const traced = runs.filter(isTracedRun).length;
+ const newest = live[0];
+
+ return (
+
+
+
Ingest endpoint
+
+ {ingest?.claudeCode ? (
+ <>
+ ready · protocol {ingest.claudeCode.protocol}
+
+ /api/ingest/claude-code
+ >
+ ) : ingest ? (
+ 'turned off: the runtime was started with RUNLOOP_TRACE=off'
+ ) : (
+ 'not available: update Runloop and restart the runtime'
+ )}
+
+
+
+
Live sessions
+
+ {live.length === 0 ? (
+ 'none'
+ ) : (
+ <>
+ {live.length} ·{' '}
+ {
+ selectRun(store, newest!.runId);
+ router.push(ROUTES.world);
+ }}
+ >
+ watch {runSubtitle(newest!) ?? 'the newest'}
+
+ >
+ )}
+
+
+
+
Sessions recorded
+ {traced}
+
+
+
Last event from Claude Code
+ {last ? `${formatAgo(Math.max(0, Date.now() - Date.parse(last)))} ago · ${formatWhen(last)}` : none yet }
+
+
+ );
+}
diff --git a/apps/studio/src/components/pages/OverviewPage.tsx b/apps/studio/src/components/pages/OverviewPage.tsx
new file mode 100644
index 0000000..5481217
--- /dev/null
+++ b/apps/studio/src/components/pages/OverviewPage.tsx
@@ -0,0 +1,483 @@
+'use client';
+import type { RunSummary } from '@runloop-world/events';
+import { useRouter } from 'next/navigation';
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+import { connectCommands } from '../../lib/claude-code';
+import { apiBase } from '../../lib/config';
+import { useStudio, useStudioStore } from '../../state/context';
+import { ROUTES } from '../../state/deeplink';
+import { formatWhen, runDurationMs } from '../../state/format';
+import { selectRun, useTicker } from '../../state/hooks';
+import { nextStep, ONBOARDING_STEPS, stepsDone, type OnboardingStep } from '../../state/onboarding';
+import { activityText, formatAgo, groupRuns, isTracedRun, lastTracedEventAt, runSourceLabel, runSubtitle } from '../../state/runs';
+import { formatDuration } from '../../state/timeline';
+import { runStatusView } from '../../state/view';
+import { Button, ButtonLink } from '../../ui/Button';
+import { Card } from '../../ui/Card';
+import { Chip, StatusBadge } from '../../ui/Chip';
+import { CommandSnippet } from '../../ui/CommandSnippet';
+import { Icon } from '../../ui/Icon';
+import { Kbd, modKey } from '../../ui/Kbd';
+import { EmptyState, Skeleton } from '../../ui/States';
+import { OfflineState } from '../RunStates';
+import { useStartDemo } from '../useStartDemo';
+import styles from './Pages.module.css';
+
+function SourceChip({ run }: { run: RunSummary }) {
+ const traced = isTracedRun(run);
+ return (
+
+ {runSourceLabel(run)}
+
+ );
+}
+
+/** Overview: getting started, runtime health, live sessions and run history. */
+export default function OverviewPage() {
+ const store = useStudioStore();
+ const router = useRouter();
+ const runtime = useStudio((s) => s.runtime);
+ const runs = useStudio((s) => s.runs);
+ const onboarding = useStudio((s) => s.onboarding);
+ useStudio((s) => s.apiBaseOverride); // re-render when the runtime URL changes
+ const groups = useMemo(() => groupRuns(runs), [runs]);
+ useTicker(groups.live.length > 0, 5000);
+
+ const open = (runId: string, route: string = ROUTES.world) => {
+ selectRun(store, runId);
+ router.push(route);
+ };
+
+ const online = runtime.status === 'online';
+ const done = stepsDone(onboarding, runs);
+ const allDone = nextStep(done) === null;
+ const showChecklist = online && !onboarding.dismissed && !allDone;
+
+ return (
+
+
+
+
+ {runtime.status === 'offline' ? (
+
+
+
+ ) : (
+
+ {showChecklist ? : }
+
+
+ )}
+
+ {/* Offline, the runtime's sessions and runs are unknown: say nothing rather than "none". */}
+ {runtime.status !== 'offline' && (
+ <>
+
+
+ {runtime.status === 'unknown' ? (
+
+ ) : groups.live.length === 0 ? (
+
+ Run Claude Code with the plugin installed, or start the demo mission. A session appears here with its first recorded event, and the Studio opens it when nothing else is open.
+
+ ) : (
+
+ {groups.live.map((r) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {runtime.status === 'unknown' ? (
+
+
+
+
+
+ ) : groups.traced.length + groups.demo.length === 0 ? (
+
+
+
+ Finished runs are kept here to replay. Start one from the checklist above, or from a terminal: pnpm demo.
+
+
+
+ ) : (
+
+
+
+
+ #
+ Run
+ Status
+ Source
+ Started
+
+ Duration
+
+
+ Events
+
+
+ Actions
+
+
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
+function HistoryGroup({ title, icon, runs, all, onOpen }: { title: string; icon: 'eye' | 'terminal'; runs: RunSummary[]; all: RunSummary[]; onOpen: (id: string, route?: string) => void }) {
+ if (runs.length === 0) return null;
+ return (
+
+
+
+
+
+ {title}
+ {runs.length}
+
+
+
+ {runs.map((r) => {
+ const n = all.length - all.indexOf(r);
+ const ms = runDurationMs(r);
+ const sub = runSubtitle(r);
+ return (
+
+ {n}
+
+ onOpen(r.runId)}>
+ {r.title}
+
+ {sub && {sub} }
+
+
+
+
+
+
+
+ {formatWhen(r.createdAt)}
+ {ms !== null ? formatDuration(ms) : not reported }
+ {r.lastSequence}
+
+ onOpen(r.runId, ROUTES.traces)} aria-label={`Open run ${n} in Traces`}>
+ Traces
+
+
+
+ );
+ })}
+
+ );
+}
+
+function LiveSession({ run, n, onOpen }: { run: RunSummary; n: number; onOpen: (id: string, route?: string) => void }) {
+ const traced = isTracedRun(run);
+ const ms = runDurationMs(run);
+ const activity = traced ? activityText(run, Date.now()) : null;
+ const sub = runSubtitle(run);
+ return (
+
+
+
+
+ #{n} {run.title}
+
+
+ {sub && {sub} · }
+ started {formatWhen(run.createdAt)} · {run.lastSequence} events{ms !== null ? ` · ${formatDuration(ms)}` : ''}
+ {activity && (
+ <>
+ {' · '}
+
+ {activity}
+
+ >
+ )}
+
+
+
+
+
+ onOpen(run.runId)}>
+ Watch
+
+ onOpen(run.runId, ROUTES.traces)} aria-label={`Open run ${n} in Traces`}>
+ Traces
+
+
+
+ );
+}
+
+function HealthCard() {
+ const runtime = useStudio((s) => s.runtime);
+ const runs = useStudio((s) => s.runs);
+ const store = useStudioStore();
+ const router = useRouter();
+ const online = runtime.status === 'online';
+ const ingest = runtime.ingest;
+ const lastTraced = lastTracedEventAt(runs);
+ const tracedCount = runs.filter(isTracedRun).length;
+ const checking = checking… ;
+
+ return (
+
+
+ {runtime.status === 'unknown' ? (
+
+ ) : (
+
+
+
+
+
+
Online{runtime.version ? ` · v${runtime.version}` : ''}
+
+ {apiBase()}
+
+
+
+ )}
+
+
+
Claude Code ingest
+
+ {!online ? (
+ checking
+ ) : ingest?.claudeCode ? (
+
+ ready · protocol {ingest.claudeCode.protocol}
+
+ ) : ingest ? (
+ turned off (RUNLOOP_TRACE=off)
+ ) : (
+ not in this runtime
+ )}
+
+
+
+
Live Claude Code sessions
+ {online ? runtime.tracedRunIds.length : checking}
+
+
+
Last Claude Code event
+ {!online ? checking : lastTraced ? `${formatAgo(Math.max(0, Date.now() - Date.parse(lastTraced)))} ago · ${formatWhen(lastTraced)}` : none recorded yet }
+
+
+
Demo run in progress
+
+ {!online ? (
+ checking
+ ) : runtime.activeRunId ? (
+ {
+ selectRun(store, runtime.activeRunId!);
+ router.push(ROUTES.world);
+ }}
+ >
+ Watch it
+
+ ) : (
+ 'none'
+ )}
+
+
+
+
Runs recorded
+ {online ? `${runs.length} · ${tracedCount} traced, ${runs.length - tracedCount} executed` : checking}
+
+
+
+
+ );
+}
+
+/** When the checklist is done or hidden: the two ways to get a run. */
+function QuickStart() {
+ const demo = useStartDemo();
+ return (
+
+
+
+
+
+ Trace Claude Code
+
+
Use Claude Code as normal with the plugin installed. Runloop records what it reports and never runs or approves anything.
+
+
+ Connect Claude Code
+
+
+
+
+
+ Demo mission
+
+
A scripted agent, real tools: the runtime edits files and runs a real test suite. Labelled Executed.
+
+ void demo.start()} busy={demo.starting} disabled={demo.disabledReason !== null} title={demo.disabledReason ?? undefined}>
+ Run demo mission
+
+
+
+
+
+
+ );
+}
+
+const STEP_TITLE: Record = {
+ demo: 'Run the demo mission',
+ connect: 'Connect Claude Code',
+ open: 'Open a run',
+ failure: 'Inspect a failure',
+ replay: 'Replay a moment',
+};
+
+/** Getting started: teaches run demo → connect Claude Code → open a run → inspect a failure → replay. */
+function Checklist({ done, onOpen }: { done: Record; onOpen: (id: string, route?: string) => void }) {
+ const store = useStudioStore();
+ const runs = useStudio((s) => s.runs);
+ const ingest = useStudio((s) => s.runtime.ingest);
+ const demo = useStartDemo();
+ const next = nextStep(done);
+ const count = ONBOARDING_STEPS.filter((s) => done[s]).length;
+ const cmds = connectCommands(apiBase());
+ const newest = runs[0];
+ const [mod, setMod] = useState('Ctrl');
+ useEffect(() => setMod(modKey()), []);
+
+ const body: Record = {
+ demo: (
+ <>
+ A scripted agent edits files and runs a real test suite in a throwaway copy of a small repository. Two tests fail, then the fix makes them pass.
+
+ void demo.start()} busy={demo.starting} disabled={demo.disabledReason !== null} title={demo.disabledReason ?? undefined}>
+ Run demo mission
+
+
+ >
+ ),
+ connect: (
+ <>
+
+ Start Claude Code with the Runloop plugin, from the root of your Runloop checkout. Your next prompt appears under Live sessions.
+ {ingest && !ingest.claudeCode && ' This runtime has Claude Code ingest turned off; restart it without RUNLOOP_TRACE=off.'}
+
+
+
+
+ Marketplace install, doctor and privacy
+
+
+ >
+ ),
+ open: (
+ <>
+ Watch a run in the Live World: robots for each agent, stations for each kind of tool call, and the timeline beside them.
+ {newest && (
+
+ onOpen(newest.runId)}>
+ Open the newest run
+
+
+ )}
+ >
+ ),
+ failure: (
+ <>
+
+ In Traces, select a red row (a failing test run, a tool error) to see the exact call, its reported output and what caused it. Or press and choose “Jump to the next failure”.
+
+ {newest && (
+
+ {
+ store.getState().setFilter({ text: '', agents: [], tools: [], categories: [], statuses: ['error'], types: [] });
+ onOpen(newest.runId, ROUTES.traces);
+ }}
+ >
+ Show failures in the newest run
+
+
+ )}
+ >
+ ),
+ replay: (
+
+ Drag the scrubber or press to replay from the start; and step event by event. The world, timeline and inspector show exactly the recorded state; nothing is re-executed.
+
+ ),
+ };
+
+ return (
+ store.getState().setOnboarding({ dismissed: true })} title="Hide the checklist (kept hidden in this browser)">
+ Hide
+
+ }
+ >
+
+
+
+
+
+ {ONBOARDING_STEPS.map((step, i) => {
+ const isDone = done[step];
+ const current = step === next;
+ return (
+
+
+ {isDone ? : i + 1}
+
+
+
+ {STEP_TITLE[step]}
+ {isDone ? ' (done)' : current ? ' (next)' : ''}
+
+ {current && body[step]}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/pages/Pages.module.css b/apps/studio/src/components/pages/Pages.module.css
new file mode 100644
index 0000000..54d4ea7
--- /dev/null
+++ b/apps/studio/src/components/pages/Pages.module.css
@@ -0,0 +1,675 @@
+/* ── Run pages (Live World, Traces) ───────────────────────────── */
+.runPage {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-3);
+ padding: 0 var(--rl-space-4) var(--rl-space-4);
+}
+.split {
+ flex: 1;
+ min-height: 0;
+}
+.stageColumn {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ padding-bottom: var(--rl-space-2);
+ padding-right: var(--rl-space-2);
+}
+.timelineCard {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin: var(--rl-space-2) var(--rl-space-2) 0 0;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+}
+.timelineHead {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-4);
+ min-width: 0;
+ padding: var(--rl-space-3) var(--rl-space-4) 0;
+}
+.cardLabel {
+ flex: none;
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.filters {
+ padding: var(--rl-space-2) var(--rl-space-4);
+}
+.traceCard {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin-right: var(--rl-space-2);
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+}
+.traceHead {
+ padding: var(--rl-space-1) var(--rl-space-3) 0;
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+}
+.tracePanel {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.panelScroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--rl-space-4) var(--rl-space-4) var(--rl-space-6);
+}
+.mobileStack {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-3);
+}
+.mobileStack .timelineCard {
+ margin: 0;
+ max-height: 70dvh;
+}
+.mobileCard {
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+}
+.mobileNote {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.sheet {
+ height: 82dvh;
+}
+.sheet > section {
+ border-radius: 0;
+ animation: none;
+}
+
+@media (max-width: 760px) {
+ .runPage {
+ padding: 0 var(--rl-space-4) var(--rl-space-4);
+ }
+ .traceCard {
+ margin: 0;
+ min-height: 60dvh;
+ }
+ .timelineHead {
+ flex-wrap: wrap;
+ gap: var(--rl-space-2);
+ }
+}
+
+/* ── Content pages (Overview, Integrations, Settings) ──────────── */
+.page {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--rl-space-2) var(--rl-space-8) var(--rl-space-8);
+}
+.pageInner {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-6);
+}
+.pageHead {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--rl-space-4);
+ padding-top: var(--rl-space-4);
+}
+.h1 {
+ font-size: var(--rl-text-h1);
+ font-weight: 600;
+ letter-spacing: var(--rl-tracking-tight);
+ line-height: 1.2;
+}
+.lede {
+ margin-top: var(--rl-space-2);
+ max-width: 720px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-md);
+}
+.grid2 {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: var(--rl-space-4);
+}
+.grid3 {
+ display: grid;
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 1fr);
+ gap: var(--rl-space-4);
+}
+.cardBody {
+ padding: 0 var(--rl-space-5) var(--rl-space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-3);
+}
+.cardText {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+}
+.cardActions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rl-space-2);
+ margin-top: auto;
+ padding-top: var(--rl-space-2);
+}
+.kv {
+ display: flex;
+ flex-direction: column;
+}
+.kv > div {
+ display: grid;
+ grid-template-columns: minmax(120px, 40%) minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ padding: 7px 0;
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+ font-size: var(--rl-text-xs);
+}
+.kv > div:last-child {
+ box-shadow: none;
+}
+.kv dt {
+ color: var(--rl-text-muted);
+}
+.kv dd {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.none {
+ color: var(--rl-text-faint);
+ font-style: italic;
+}
+.bigStatus {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+}
+.bigStatusIcon {
+ display: grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.bigStatusIcon[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.bigStatusIcon[data-tone='err'] {
+ color: var(--rl-failure);
+ background: color-mix(in srgb, var(--rl-failure) 12%, transparent);
+}
+.bigStatusTitle {
+ font-size: var(--rl-text-lg);
+ font-weight: 600;
+}
+.bigStatusSub {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.option {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-4);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.optionHead {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ font-weight: 600;
+}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--rl-text-xs);
+}
+.table th,
+.table td {
+ padding: 10px var(--rl-space-3);
+ text-align: left;
+ white-space: nowrap;
+}
+.table th:first-child,
+.table td:first-child {
+ padding-left: var(--rl-space-5);
+}
+.table th:last-child,
+.table td:last-child {
+ padding-right: var(--rl-space-5);
+}
+.table thead th {
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.table tbody tr {
+ box-shadow: inset 0 1px 0 var(--rl-line);
+ transition: background-color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.table tbody tr:hover {
+ background: var(--rl-surface-2);
+}
+.num {
+ font-family: var(--rl-font-mono);
+ color: var(--rl-text-muted);
+}
+.right {
+ text-align: right !important;
+}
+.titleCell {
+ max-width: 360px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rowLink {
+ color: var(--rl-text);
+ font-weight: 500;
+ border-radius: 4px;
+}
+.rowLink:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.tableWrap {
+ overflow-x: auto;
+ padding-bottom: var(--rl-space-2);
+}
+.sessions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+}
+.session {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ padding: var(--rl-space-3) var(--rl-space-4);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+}
+.sessionMain {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.sessionTitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+.sessionMeta {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.liveDot {
+ width: 8px;
+ height: 8px;
+ flex: none;
+ border-radius: 50%;
+ background: var(--rl-accent-text);
+ box-shadow: 0 0 0 3px var(--rl-accent-soft);
+}
+.steps {
+ counter-reset: step;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-4);
+}
+.step {
+ counter-increment: step;
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ gap: var(--rl-space-3);
+}
+.step::before {
+ content: counter(step);
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 600;
+}
+.stepBody {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ min-width: 0;
+}
+.stepTitle {
+ font-weight: 600;
+}
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ font-size: var(--rl-text-sm);
+}
+.list li {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ gap: var(--rl-space-2);
+ color: var(--rl-text-muted);
+}
+.list li svg {
+ margin-top: 3px;
+}
+.list li strong {
+ color: var(--rl-text);
+ font-weight: 500;
+}
+.yes svg {
+ color: var(--rl-success);
+}
+.no svg {
+ color: var(--rl-text-faint);
+}
+.settingRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--rl-space-4);
+ padding: var(--rl-space-4) var(--rl-space-5);
+ box-shadow: inset 0 1px 0 var(--rl-line);
+}
+.settingLabel {
+ font-weight: 500;
+}
+.settingHelp {
+ margin-top: 2px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.field {
+ display: flex;
+ gap: var(--rl-space-2);
+ align-items: center;
+}
+.input {
+ width: 300px;
+ height: var(--rl-control-h);
+ padding: 0 var(--rl-space-3);
+ border: 0;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-xs);
+}
+.input[aria-invalid='true'] {
+ box-shadow: inset 0 0 0 1px var(--rl-failure);
+}
+.fieldError {
+ margin-top: 6px;
+ color: var(--rl-failure);
+ font-size: var(--rl-text-xs);
+}
+.fieldWarn {
+ margin-top: 6px;
+ color: var(--rl-warning);
+ font-size: var(--rl-text-xs);
+}
+.kbdTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--rl-text-xs);
+}
+.kbdTable td {
+ padding: 8px var(--rl-space-5);
+ box-shadow: inset 0 1px 0 var(--rl-line);
+}
+.kbdTable td:last-child {
+ text-align: right;
+}
+
+/* ── Integrations ──────────────────────────────────────────────── */
+.hint {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.hint svg {
+ flex: none;
+ margin-top: 2px;
+}
+.statusStrip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--rl-space-2);
+}
+.statusStrip > div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ padding: var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-surface-2);
+ font-size: var(--rl-text-xs);
+}
+.statusStrip dt {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.statusStrip dd {
+ overflow-wrap: anywhere;
+}
+.small {
+ font-size: var(--rl-text-2xs);
+ color: var(--rl-text-muted);
+}
+.statusStrip .none {
+ color: var(--rl-text-muted);
+}
+
+/* ── Overview ──────────────────────────────────────────────────── */
+.overviewTop {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
+ gap: var(--rl-space-4);
+ align-items: start;
+}
+.progress {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rl-surface-2);
+ overflow: hidden;
+}
+.progress > span {
+ display: block;
+ height: 100%;
+ border-radius: 2px;
+ background: var(--rl-success);
+ transition: width var(--rl-dur-emphasis) var(--rl-ease-out);
+}
+.checklist {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.check {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr);
+ gap: var(--rl-space-3);
+ padding: var(--rl-space-2) var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+}
+.check[data-current] {
+ padding: var(--rl-space-3);
+ background: var(--rl-surface-2);
+}
+.checkMark {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1px var(--rl-line-strong);
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+}
+.check[data-done] .checkMark {
+ box-shadow: none;
+ background: color-mix(in srgb, var(--rl-success) 16%, transparent);
+ color: var(--rl-success);
+}
+.check[data-current] .checkMark {
+ box-shadow: none;
+ background: var(--rl-accent-fill);
+ color: var(--rl-on-accent);
+}
+.checkBody {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+ min-width: 0;
+}
+.checkTitle {
+ line-height: 22px;
+ font-weight: 500;
+}
+.check[data-done] .checkTitle {
+ color: var(--rl-text-muted);
+}
+.check[data-current] .cardText {
+ color: var(--rl-text-muted);
+}
+.check[data-current] .cardActions {
+ margin-top: 0;
+ padding-top: 0;
+}
+.groupRow th {
+ padding-top: var(--rl-space-4) !important;
+ padding-bottom: 6px !important;
+ text-align: left;
+}
+.groupTitle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ font-weight: 600;
+}
+.groupTitle svg {
+ color: var(--rl-text-muted);
+}
+.groupCount {
+ min-width: 18px;
+ padding: 0 6px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+ text-align: center;
+}
+.table tbody tr.groupRow:hover {
+ background: none;
+}
+.rowSub {
+ display: block;
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.sessionNum {
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-xs);
+ font-weight: 400;
+}
+.mono {
+ font-family: var(--rl-font-mono);
+}
+.activity {
+ color: var(--rl-accent-text);
+ font-weight: 600;
+}
+.activity[data-idle] {
+ color: var(--rl-text);
+}
+.sessionActions {
+ display: flex;
+ gap: var(--rl-space-1);
+}
+
+@media (max-width: 1100px) {
+ .overviewTop {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .statusStrip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .grid3 {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+ .grid3 > :first-child {
+ grid-column: 1 / -1;
+ }
+}
+@media (max-width: 760px) {
+ .session {
+ flex-wrap: wrap;
+ }
+ .sessionMain {
+ flex-basis: calc(100% - 24px);
+ }
+ .page {
+ padding: var(--rl-space-2) var(--rl-space-4) var(--rl-space-6);
+ }
+ .grid2,
+ .grid3 {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .h1 {
+ font-size: var(--rl-text-h2);
+ }
+ .settingRow {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .input {
+ width: 100%;
+ }
+ .field {
+ flex-wrap: wrap;
+ }
+}
diff --git a/apps/studio/src/components/pages/SettingsPage.tsx b/apps/studio/src/components/pages/SettingsPage.tsx
new file mode 100644
index 0000000..3936d10
--- /dev/null
+++ b/apps/studio/src/components/pages/SettingsPage.tsx
@@ -0,0 +1,235 @@
+'use client';
+import { useEffect, useState, type ReactNode } from 'react';
+import { DEFAULT_API_BASE, isLoopbackUrl, normalizeApiBase, validateApiBase } from '../../lib/config';
+import { useStudio, useStudioStore } from '../../state/context';
+import { Button } from '../../ui/Button';
+import { Card } from '../../ui/Card';
+import { Chip } from '../../ui/Chip';
+import { Icon } from '../../ui/Icon';
+import { Kbd, modKey } from '../../ui/Kbd';
+import { SegmentedControl } from '../../ui/SegmentedControl';
+import { toast } from '../../ui/Toast';
+import styles from './Pages.module.css';
+
+function Setting({ label, help, children }: { label: string; help: ReactNode; children: ReactNode }) {
+ return (
+
+ );
+}
+
+const SHORTCUTS: [string, string[]][] = [
+ ['Search, jump to a run or page, run a command', ['mod', 'K']],
+ ['Focus the search on Traces and the timeline', ['/']],
+ ['Play or pause', ['Space']],
+ ['Previous / next event', ['←', '→']],
+ ['Go live', ['L']],
+ ['Previous / next agent', ['[', ']']],
+ ['Follow the selected agent', ['F']],
+ ['Move between timeline or log rows', ['↑', '↓']],
+ ['Clear the selection, close a dialog', ['Esc']],
+];
+
+/** Settings: appearance, motion, render quality, runtime URL, privacy and retention, shortcuts. */
+export default function SettingsPage() {
+ const store = useStudioStore();
+ const themePref = useStudio((s) => s.themePref);
+ const motionPref = useStudio((s) => s.motionPref);
+ const quality = useStudio((s) => s.quality);
+ const override = useStudio((s) => s.apiBaseOverride);
+ const runtime = useStudio((s) => s.runtime.status);
+ const [draft, setDraft] = useState(override ?? DEFAULT_API_BASE);
+ const [mod, setMod] = useState('Ctrl');
+ useEffect(() => setMod(modKey()), []);
+ useEffect(() => setDraft(override ?? DEFAULT_API_BASE), [override]);
+ const s = store.getState();
+
+ const error = validateApiBase(draft);
+ const normalized = normalizeApiBase(draft);
+ const current = override ?? DEFAULT_API_BASE;
+ const changed = !error && normalized !== current;
+
+ const saveUrl = () => {
+ if (error) return;
+ s.setApiBase(normalized === DEFAULT_API_BASE ? null : normalized);
+ toast({ tone: 'ok', title: 'Runtime URL saved', description: `Connecting to ${normalized}.` });
+ };
+
+ const resetLayout = () => {
+ try {
+ for (const key of Object.keys(window.localStorage)) if (key.startsWith('runloop.layout.')) window.localStorage.removeItem(key);
+ toast({ tone: 'ok', title: 'Panel sizes reset', description: 'They return to the defaults the next time each page opens.' });
+ } catch {
+ toast({ tone: 'err', title: 'Could not reset panel sizes', description: 'This browser blocks local storage.' });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ s.setThemePref(v)}
+ options={[
+ { value: 'system', label: 'System', icon: 'monitor' },
+ { value: 'dark', label: 'Dark', icon: 'moon' },
+ { value: 'light', label: 'Light', icon: 'sun' },
+ ]}
+ />
+
+
+ s.setMotionPref(v)}
+ options={[
+ { value: 'system', label: 'System' },
+ { value: 'reduce', label: 'Reduced' },
+ { value: 'full', label: 'Full' },
+ ]}
+ />
+
+
+ s.setQuality(v)}
+ options={[
+ { value: 'high', label: 'High' },
+ { value: 'low', label: 'Low' },
+ ]}
+ />
+
+
+
+ Reset panel sizes
+
+
+
+
+
+ online
+
+ ) : runtime === 'offline' ? (
+
+ offline
+
+ ) : (
+ checking
+ )
+ }
+ >
+
+ Where the Studio finds the local runtime. Build default: {DEFAULT_API_BASE} (set NEXT_PUBLIC_RUNLOOP_API when building to change it).
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ Where runs live. In the runtime’s data directory: .runloop/ in the repository by default, or RUNLOOP_DATA_DIR. Events
+ are append-only in runloop.db; artifacts sit next to it.
+
+
+
+
+
+ Retention. Runs are kept until you delete them. There is no deletion command yet: stop the runtime and remove the data directory to erase
+ everything.
+
+
+
+
+
+ Nothing leaves this machine. No telemetry, no accounts; fonts are bundled with the Studio.
+
+
+
+
+
+ Read access. Any process on this machine can read the runtime’s API, including prompts in traced runs.
+
+
+
+
+
+
+
+
+
+ {SHORTCUTS.map(([what, keys]) => (
+
+ {what}
+
+ (k === 'mod' ? mod : k))} />
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/pages/TracesPage.tsx b/apps/studio/src/components/pages/TracesPage.tsx
new file mode 100644
index 0000000..348f9df
--- /dev/null
+++ b/apps/studio/src/components/pages/TracesPage.tsx
@@ -0,0 +1,90 @@
+'use client';
+import { useStudio, useStudioStore } from '../../state/context';
+import type { Tab } from '../../state/store';
+import { SplitGroup, SplitHandle, SplitPanel } from '../../ui/SplitPanels';
+import { Tabs } from '../../ui/Tabs';
+import { MOBILE_QUERY, useMediaQuery } from '../../ui/useMediaQuery';
+import FilterBar from '../FilterBar';
+import Inspector from '../Inspector';
+import LogsPanel from '../LogsPanel';
+import MetadataPanel from '../MetadataPanel';
+import ReplayBar from '../ReplayBar';
+import RunHeader from '../RunHeader';
+import RunMetrics from '../RunMetrics';
+import { OfflineBanner, RunGate } from '../RunStates';
+import StatePanel from '../StatePanel';
+import TimelinePanel from '../TimelinePanel';
+import { useTraceModel } from '../useTraceModel';
+import styles from './Pages.module.css';
+import { MobileInspector } from './WorldPage';
+
+/** Traces: investigate one run (timeline, logs, state, metadata) with search, filters and the inspector; no world. */
+export default function TracesPage() {
+ const mobile = useMediaQuery(MOBILE_QUERY);
+ return (
+
+
+
+
+
+ {mobile ? (
+ <>
+
+
+ >
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function TraceContent() {
+ const store = useStudioStore();
+ const tab = useStudio((s) => s.tab);
+ const model = useTraceModel();
+ const filterable = tab === 'timeline' || tab === 'logs';
+ const items: { id: Tab; label: string; count?: number }[] = [
+ { id: 'timeline', label: 'Timeline', count: model.visibleRows.length },
+ { id: 'logs', label: 'Logs', count: model.visibleEvents.length },
+ { id: 'metrics', label: 'Metrics' },
+ { id: 'state', label: 'State' },
+ { id: 'metadata', label: 'Metadata' },
+ ];
+ return (
+
+
+ store.getState().setTab(t)} label="Trace views" idPrefix="trace" />
+
+ {filterable && (
+
+ {tab === 'timeline' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {tab === 'timeline' &&
}
+ {tab === 'logs' &&
}
+ {tab === 'metrics' && (
+
+ store.getState().setTab('timeline')} />
+
+ )}
+ {tab === 'state' &&
}
+ {tab === 'metadata' &&
}
+
+
+ );
+}
diff --git a/apps/studio/src/components/pages/WorldPage.tsx b/apps/studio/src/components/pages/WorldPage.tsx
new file mode 100644
index 0000000..afe70c5
--- /dev/null
+++ b/apps/studio/src/components/pages/WorldPage.tsx
@@ -0,0 +1,101 @@
+'use client';
+import { useStudio, useStudioStore } from '../../state/context';
+import { Dialog } from '../../ui/Dialog';
+import { SplitGroup, SplitHandle, SplitPanel } from '../../ui/SplitPanels';
+import { MOBILE_QUERY, useMediaQuery } from '../../ui/useMediaQuery';
+import AgentFlow from '../AgentFlow';
+import AgentsTable from '../AgentsTable';
+import FilterBar from '../FilterBar';
+import Inspector from '../Inspector';
+import ReplayBar from '../ReplayBar';
+import RunHeader from '../RunHeader';
+import { OfflineBanner, RunGate } from '../RunStates';
+import TimelinePanel from '../TimelinePanel';
+import { useTraceModel, type TraceModel } from '../useTraceModel';
+import WorldStage from '../WorldStage';
+import styles from './Pages.module.css';
+
+/** Live World: watch execution (3D world or the 2D Network view), scrub it, and inspect the selection. */
+export default function WorldPage() {
+ const mobile = useMediaQuery(MOBILE_QUERY);
+ return (
+
+
+
+
+ {mobile ? : }
+
+
+ );
+}
+
+function TimelineCard({ model }: { model: TraceModel }) {
+ return (
+
+ );
+}
+
+function DesktopWorld() {
+ const model = useTraceModel();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Phones get traces, summaries and inspectors, never a desktop-sized 3D scene. */
+function MobileWorld() {
+ const model = useTraceModel();
+ return (
+
+
+
The 3D world opens on wider screens. Everything it shows is in the agents table and timeline below.
+
+
+
+
+ );
+}
+
+/** On phones the inspector is a bottom sheet that opens with a selection. */
+export function MobileInspector() {
+ const store = useStudioStore();
+ const open = useStudio((s) => s.selectedSeq !== null || s.selectedAgentId !== null);
+ const close = () => store.getState().clearSelection();
+ return (
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/useStartDemo.ts b/apps/studio/src/components/useStartDemo.ts
new file mode 100644
index 0000000..cd555fc
--- /dev/null
+++ b/apps/studio/src/components/useStartDemo.ts
@@ -0,0 +1,47 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { useCallback, useState } from 'react';
+import { ApiError, startRun } from '../lib/api';
+import { DEMO_MISSION_ID } from '../lib/config';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES } from '../state/deeplink';
+import { selectRun } from '../state/hooks';
+import { toast } from '../ui/Toast';
+
+/** Start the scripted demo mission, open it in the Live World, and report what happened. */
+export function useStartDemo(): { start: () => Promise; starting: boolean; disabledReason: string | null } {
+ const store = useStudioStore();
+ const router = useRouter();
+ const runtime = useStudio((s) => s.runtime);
+ const [starting, setStarting] = useState(false);
+
+ const disabledReason =
+ runtime.status !== 'online' ? 'The local runtime is offline' : runtime.activeRunId ? 'A demo run is already in progress' : null;
+
+ const start = useCallback(async () => {
+ setStarting(true);
+ try {
+ const { runId } = await startRun(DEMO_MISSION_ID);
+ selectRun(store, runId);
+ router.push(ROUTES.world);
+ toast({ tone: 'ok', title: 'Demo mission started', description: 'Scripted agent, real tools: the runtime edits files and runs tests.' });
+ } catch (err) {
+ const active = store.getState().runtime.activeRunId;
+ if (err instanceof ApiError && err.status === 409 && active) {
+ selectRun(store, active);
+ router.push(ROUTES.world);
+ toast({ tone: 'warn', title: 'A run is already in progress', description: 'Opened it instead.' });
+ } else {
+ toast({
+ tone: 'err',
+ title: 'Could not start the demo mission',
+ description: `${err instanceof Error ? err.message : String(err)}. Nothing was recorded; try again.`,
+ });
+ }
+ } finally {
+ setStarting(false);
+ }
+ }, [store, router]);
+
+ return { start, starting, disabledReason };
+}
diff --git a/apps/studio/src/components/useStudioCommands.ts b/apps/studio/src/components/useStudioCommands.ts
new file mode 100644
index 0000000..d2e75f7
--- /dev/null
+++ b/apps/studio/src/components/useStudioCommands.ts
@@ -0,0 +1,145 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { useMemo } from 'react';
+import type { Command } from '../state/commands';
+import { useStudio, useStudioStore } from '../state/context';
+import { ROUTES, linkFromView, linkTo } from '../state/deeplink';
+import { isRunPage } from '../state/format';
+import { selectRun } from '../state/hooks';
+import { isTracedRun, runSourceLabel, shortId } from '../state/runs';
+import { runStatusView } from '../state/view';
+import { toast } from '../ui/Toast';
+import { useStartDemo } from './useStartDemo';
+
+/** Everything the ⌘K palette can do, built from the current state. */
+export function useStudioCommands(pathname: string): Command[] {
+ const store = useStudioStore();
+ const router = useRouter();
+ const runs = useStudio((s) => s.runs);
+ const runId = useStudio((s) => s.runId);
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const themePref = useStudio((s) => s.themePref);
+ const motionPref = useStudio((s) => s.motionPref);
+ const quality = useStudio((s) => s.quality);
+ const cameraMode = useStudio((s) => s.cameraMode);
+ const worldView = useStudio((s) => s.worldView);
+ const follow = useStudio((s) => s.follow);
+ const demo = useStartDemo();
+
+ return useMemo(() => {
+ const s = () => store.getState();
+ const go = (href: string) => router.push(href);
+ const cmds: Command[] = [];
+
+ const nav: [string, string, string][] = [
+ [ROUTES.overview, 'Overview', 'home health runs'],
+ [ROUTES.world, 'Live World', '3d world agents watch'],
+ [ROUTES.traces, 'Traces', 'timeline logs inspect search filter'],
+ [ROUTES.integrations, 'Integrations', 'claude code plugin connect install'],
+ [ROUTES.settings, 'Settings', 'theme motion quality runtime url privacy shortcuts'],
+ ];
+ for (const [href, label, keywords] of nav) {
+ cmds.push({ id: `nav:${href}`, group: 'Go to', label, keywords, hint: pathname.startsWith(href) ? 'current' : undefined, run: () => go(href) });
+ }
+
+ cmds.push({
+ id: 'demo',
+ group: 'Actions',
+ label: 'Run demo mission',
+ keywords: 'start scripted example',
+ hint: demo.disabledReason ?? undefined,
+ disabled: demo.disabledReason !== null || demo.starting,
+ run: () => void demo.start(),
+ });
+ cmds.push({ id: 'connect', group: 'Actions', label: 'Connect Claude Code', keywords: 'plugin install tracer', run: () => go(ROUTES.integrations) });
+ const liveTraced = runs.find((r) => r.status === 'running' && isTracedRun(r));
+ if (liveTraced) {
+ cmds.push({
+ id: 'live-session',
+ group: 'Actions',
+ label: 'Watch the newest live Claude Code session',
+ keywords: 'traced live follow session',
+ hint: liveTraced.runId === runId ? 'open' : undefined,
+ run: () => {
+ selectRun(store, liveTraced.runId);
+ go(ROUTES.world);
+ },
+ });
+ }
+
+ if (runId) {
+ const hasEvents = lastSequence > 0;
+ cmds.push(
+ { id: 'live', group: 'Playback', label: 'Go live', keywords: 'latest follow', shortcut: ['L'], disabled: !hasEvents, run: () => s().goLive() },
+ { id: 'play', group: 'Playback', label: 'Play / pause', keywords: 'replay', shortcut: ['Space'], disabled: !hasEvents, run: () => s().togglePlay() },
+ {
+ id: 'next-failure',
+ group: 'Playback',
+ label: 'Jump to the next failure',
+ keywords: 'error incident',
+ disabled: !hasEvents,
+ run: () => {
+ const st = s();
+ const incidents = st.stateAt(st.lastSequence).exec.incidents;
+ const next = incidents.find((i) => i.seq > st.playheadSeq) ?? incidents[0];
+ if (!next) {
+ toast({ tone: 'info', title: 'No failures in this run' });
+ return;
+ }
+ st.selectEvent(next.seq, { seek: true });
+ if (!isRunPage(pathname)) go(ROUTES.traces);
+ },
+ },
+ {
+ id: 'copy-link',
+ group: 'Actions',
+ label: 'Copy link to this moment',
+ keywords: 'share url deep link',
+ run: () => {
+ const st = s();
+ const route = pathname.startsWith(ROUTES.traces) ? ROUTES.traces : ROUTES.world;
+ // The event or agent on screen, and the playhead when it is paused (never an unrelated pair).
+ const view = linkFromView(st);
+ const at = view.at ?? (view.seq === null ? st.playheadSeq : null);
+ const url = new URL(linkTo(route, { runId, ...view, at }), window.location.origin);
+ navigator.clipboard.writeText(url.href).then(
+ () => toast({ tone: 'ok', title: 'Link copied', description: 'It opens this run at this moment.' }),
+ () => toast({ tone: 'err', title: 'Could not copy the link', description: url.href }),
+ );
+ },
+ },
+ );
+ }
+
+ cmds.push(
+ { id: 'view-world', group: 'View', label: 'Show the 3D world', keywords: 'live world view', hint: worldView === 'world' ? 'current' : undefined, run: () => (s().setWorldView('world'), go(ROUTES.world)) },
+ { id: 'view-table', group: 'View', label: 'Show the Network view (2D)', keywords: 'graph network table list view delegation', hint: worldView === 'graph' ? 'current' : undefined, run: () => (s().setWorldView('graph'), go(ROUTES.world)) },
+ { id: 'camera-iso', group: 'View', label: 'Camera: isometric 3D', keywords: 'camera iso', hint: cameraMode === 'iso' ? 'current' : undefined, run: () => s().setCameraMode('iso') },
+ { id: 'camera-top', group: 'View', label: 'Camera: top-down 2D', keywords: 'camera top map', hint: cameraMode === 'top' ? 'current' : undefined, run: () => s().setCameraMode('top') },
+ { id: 'follow', group: 'View', label: follow ? 'Stop following the selected agent' : 'Follow the selected agent', keywords: 'camera', shortcut: ['F'], run: () => s().toggleFollow() },
+ { id: 'quality', group: 'Preferences', label: quality === 'high' ? 'Render quality: low (faster)' : 'Render quality: high', keywords: 'performance glow', run: () => s().setQuality(quality === 'high' ? 'low' : 'high') },
+ );
+ for (const t of ['system', 'dark', 'light'] as const) {
+ cmds.push({ id: `theme:${t}`, group: 'Preferences', label: `Theme: ${t}`, keywords: 'appearance dark light mode', hint: themePref === t ? 'current' : undefined, run: () => s().setThemePref(t) });
+ }
+ for (const [m, label] of [['system', 'follow the system'], ['reduce', 'reduced'], ['full', 'full']] as const) {
+ cmds.push({ id: `motion:${m}`, group: 'Preferences', label: `Motion: ${label}`, keywords: 'animation reduce motion', hint: motionPref === m ? 'current' : undefined, run: () => s().setMotionPref(m) });
+ }
+
+ runs.forEach((r, i) => {
+ const n = runs.length - i;
+ cmds.push({
+ id: `run:${r.runId}`,
+ group: 'Runs',
+ label: `Run #${n} · ${r.title}${r.source ? ` · ${shortId(r.source.sessionId)}` : ''}`,
+ keywords: `${r.runId} ${r.status} ${runSourceLabel(r)} ${r.source?.sessionId ?? ''} ${r.source?.projectLabel ?? ''}`,
+ hint: r.runId === runId ? 'open' : runStatusView(r.status, r.paused, isTracedRun(r)).text,
+ run: () => {
+ selectRun(store, r.runId);
+ if (!isRunPage(pathname)) go(ROUTES.world);
+ },
+ });
+ });
+ return cmds;
+ }, [store, router, pathname, runs, runId, lastSequence, themePref, motionPref, quality, cameraMode, worldView, follow, demo]);
+}
diff --git a/apps/studio/src/components/useTraceModel.ts b/apps/studio/src/components/useTraceModel.ts
new file mode 100644
index 0000000..367f627
--- /dev/null
+++ b/apps/studio/src/components/useTraceModel.ts
@@ -0,0 +1,97 @@
+'use client';
+import type { ExecState } from '@runloop-world/core';
+import type { AnyEvent } from '@runloop-world/events';
+import { useMemo } from 'react';
+import { useStudio, useStudioStore } from '../state/context';
+import { activeFilterCount, eventSubject, facetsOf, matchesFilter, rowSubject, type CallInfo, type Facets, type FilterSubject } from '../state/filters';
+import { agentOfEvent, categoryOfStart } from '../state/selection';
+import { buildTimelineRows, rowStatusAt, type TimelineRow } from '../state/timeline';
+import { agentLook } from '../state/view';
+import type { AgentOption } from './FilterBar';
+
+export interface TraceModel {
+ events: readonly AnyEvent[];
+ /** Latest execution state (ids, names, calls, artifacts). */
+ exec: ExecState;
+ rows: TimelineRow[];
+ agents: AgentOption[];
+ /** Rows that pass the filter, with their index into `rows`. */
+ visibleRows: TimelineRow[];
+ visibleEvents: AnyEvent[];
+ rowFacets: Facets;
+ eventFacets: Facets;
+ agentOf: (e: AnyEvent) => string | null;
+}
+
+/**
+ * Timeline rows, log events and their filter subjects for the open run. The
+ * expensive parts (rows, subjects with each payload's text) are built once per
+ * new event, not per playhead move; only a status filter reads the playhead.
+ */
+export function useTraceModel(): TraceModel {
+ const store = useStudioStore();
+ const lastSequence = useStudio((s) => s.lastSequence);
+ const playheadSeq = useStudio((s) => s.playheadSeq);
+ const filter = useStudio((s) => s.filter);
+ const world = useStudio((s) => s.world);
+
+ const events = useMemo(() => [...store.getState().events()], [store, lastSequence]);
+ const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]);
+ const rows = useMemo(() => buildTimelineRows(events, store.getState().timeOfSeq, exec), [store, events, exec]);
+
+ const agents = useMemo(() => {
+ const ids = [...exec.agentOrder];
+ if (events.some((e) => !e.actorId || e.actorId === 'system')) ids.push('system');
+ return ids.map((id) => {
+ const look = agentLook(exec, world, id);
+ return { id, name: look.name, color: look.color };
+ });
+ }, [exec, world, events]);
+
+ // Filter subjects that do not depend on the playhead (text, agent, tool, ...): built once per new event.
+ const subjects = useMemo(() => {
+ const nameOf = (id: string | null) => (id ? agentLook(exec, world, id).name : 'System');
+ const agentOf = (e: AnyEvent) => agentOfEvent(e, exec);
+ const calls = new Map();
+ for (const e of events) {
+ if (e.type === 'tool.call_started') {
+ const p = e.payload as { callId: string; tool: string };
+ calls.set(p.callId, { tool: p.tool, category: categoryOfStart(e) });
+ }
+ }
+ const rowSubjects = rows.map((r) => rowSubject(r, 'info', nameOf(r.actorId)));
+ const eventSubjects = events.map((e) => {
+ const agentId = agentOf(e);
+ const callId = (e.payload as { callId?: unknown } | null)?.callId;
+ const call = typeof callId === 'string' && e.type.startsWith('tool.') ? (calls.get(callId) ?? null) : null;
+ return eventSubject(e, { exec, agentId, agentName: nameOf(agentId), call, running: false });
+ });
+ return { agentOf, rowSubjects, eventSubjects, rowFacets: facetsOf(rowSubjects), eventFacets: facetsOf(eventSubjects) };
+ }, [events, rows, exec, world]);
+
+ // Only a status filter depends on the playhead (a call is running, or not yet finished, at the playhead).
+ const statusSeq = filter.statuses.length > 0 ? playheadSeq : null;
+ const visible = useMemo(() => {
+ if (activeFilterCount(filter) === 0) return { visibleRows: rows, visibleEvents: [...events] };
+ const rowAt = (r: TimelineRow, i: number): FilterSubject => {
+ const base = subjects.rowSubjects[i]!;
+ if (statusSeq === null) return base;
+ return { ...base, statuses: rowSubject(r, rowStatusAt(r, statusSeq), '').statuses };
+ };
+ const eventAt = (e: AnyEvent, i: number): FilterSubject => {
+ const base = subjects.eventSubjects[i]!;
+ if (statusSeq === null || e.type !== 'tool.call_started') return base;
+ const c = exec.toolCalls.get((e.payload as { callId: string }).callId);
+ const running = c !== undefined && c.startedSeq <= statusSeq && (c.endedSeq === null || c.endedSeq > statusSeq);
+ return running ? { ...base, statuses: ['running', ...base.statuses] } : base;
+ };
+ return {
+ visibleRows: rows.filter((r, i) => matchesFilter(rowAt(r, i), filter)),
+ visibleEvents: events.filter((e, i) => matchesFilter(eventAt(e, i), filter)),
+ };
+ }, [subjects, rows, events, exec, filter, statusSeq]);
+
+ const model = { agentOf: subjects.agentOf, rowFacets: subjects.rowFacets, eventFacets: subjects.eventFacets, ...visible };
+
+ return { events, exec, rows, agents, ...model };
+}
diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts
new file mode 100644
index 0000000..2cc1b34
--- /dev/null
+++ b/apps/studio/src/lib/api.ts
@@ -0,0 +1,92 @@
+import {
+ apiPaths,
+ type EventsPageResponse,
+ type HealthResponse,
+ type ListRunsResponse,
+ type RunDetailResponse,
+ type RunSummary,
+ type StartRunResponse,
+} from '@runloop-world/events';
+import { apiBase } from './config';
+
+/** An HTTP error with the runtime's `{ error }` message when it sent one. */
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'ApiError';
+ }
+}
+
+export const apiUrl = (path: string) => `${apiBase()}${path}`;
+
+async function request(path: string, init?: RequestInit): Promise {
+ const res = await fetch(apiUrl(path), { cache: 'no-store', ...init });
+ if (!res.ok) throw new ApiError(res.status, await errorMessage(res));
+ return (await res.json()) as T;
+}
+
+async function errorMessage(res: Response): Promise {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === 'string') return body.error;
+ } catch {
+ // not JSON
+ }
+ return `HTTP ${res.status}`;
+}
+
+export function getHealth(signal?: AbortSignal): Promise {
+ return request(apiPaths.health, signal ? { signal } : undefined);
+}
+
+export async function listRuns(): Promise {
+ return (await request(apiPaths.runs)).runs;
+}
+
+export function getRun(runId: string): Promise {
+ return request(apiPaths.run(runId));
+}
+
+export function getEventsPage(runId: string, after: number, limit = 500): Promise {
+ return request(`${apiPaths.events(runId)}?after=${after}&limit=${limit}`);
+}
+
+/** Every event after `after`, following `hasMore` page by page. Events are raw JSON (parse them). */
+export async function getAllEvents(runId: string, after = 0): Promise {
+ const out: unknown[] = [];
+ for (let cursor = after; ; ) {
+ const page = await getEventsPage(runId, cursor);
+ out.push(...page.events);
+ if (!page.hasMore || page.nextAfter <= cursor) return out;
+ cursor = page.nextAfter;
+ }
+}
+
+/** Stop the active scripted run (the runtime records run.completed{stopped}). */
+export function stopRun(runId: string): Promise<{ run: RunSummary }> {
+ return request<{ run: RunSummary }>(apiPaths.stop(runId), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ });
+}
+
+export function startRun(missionId: string): Promise {
+ return request(apiPaths.runs, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ missionId }),
+ });
+}
+
+/** Artifact content as text (the viewer only shows text; binary shows as replacement chars). */
+export async function getArtifactText(runId: string, artifactId: string): Promise {
+ const res = await fetch(apiUrl(apiPaths.artifact(runId, artifactId)), { cache: 'no-store' });
+ if (!res.ok) throw new ApiError(res.status, await errorMessage(res));
+ return res.text();
+}
+
+export const streamUrl = (runId: string, after: number) => apiUrl(`${apiPaths.stream(runId)}?after=${after}`);
diff --git a/apps/studio/src/lib/claude-code.ts b/apps/studio/src/lib/claude-code.ts
new file mode 100644
index 0000000..1c164e2
--- /dev/null
+++ b/apps/studio/src/lib/claude-code.ts
@@ -0,0 +1,34 @@
+import { DEFAULT_RUNTIME_HOST, DEFAULT_RUNTIME_PORT } from '@runloop-world/events';
+import { normalizeApiBase, startRuntimeCommand } from './config';
+
+/** Plugin location inside the Runloop repository (commands run from the repo root). */
+export const PLUGIN_DIR = './integrations/claude-code-plugin';
+
+/** Where the plugin posts when nothing overrides it (its `runtime_url` default). */
+export const PLUGIN_DEFAULT_URL = `http://${DEFAULT_RUNTIME_HOST}:${DEFAULT_RUNTIME_PORT}`;
+
+/** Commands from integrations/claude-code-plugin/README.md. */
+export const CC = {
+ pluginDir: `claude --plugin-dir ${PLUGIN_DIR}`,
+ marketplaceAdd: '/plugin marketplace add argahv/runloop',
+ marketplaceInstall: '/plugin install runloop-tracer@runloop',
+ shellInstall: 'claude plugin install runloop-tracer@runloop --scope user',
+ doctor: `node ${PLUGIN_DIR}/scripts/runloop-hook.mjs --doctor`,
+ offForShell: 'RUNLOOP_TRACE=0 claude',
+ disable: 'claude plugin disable runloop-tracer',
+ uninstall: 'claude plugin uninstall runloop-tracer',
+} as const;
+
+/**
+ * `RUNLOOP_URL= ` when this Studio's runtime is not where the plugin posts
+ * by default, so the plugin reaches the same runtime; '' otherwise.
+ */
+export function pluginUrlPrefix(apiBase: string): string {
+ return normalizeApiBase(apiBase) === PLUGIN_DEFAULT_URL ? '' : `RUNLOOP_URL=${normalizeApiBase(apiBase)} `;
+}
+
+/** Start the runtime, then Claude Code with the plugin, both aimed at `apiBase`. */
+export function connectCommands(apiBase: string): { serve: string; claude: string; doctor: string } {
+ const prefix = pluginUrlPrefix(apiBase);
+ return { serve: startRuntimeCommand(apiBase), claude: `${prefix}${CC.pluginDir}`, doctor: `${prefix}${CC.doctor}` };
+}
diff --git a/apps/studio/src/lib/config.test.ts b/apps/studio/src/lib/config.test.ts
new file mode 100644
index 0000000..246c38a
--- /dev/null
+++ b/apps/studio/src/lib/config.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { apiBase, DEFAULT_API_BASE, DEFAULT_RUNTIME_PORT, isLoopbackUrl, setApiBaseOverride, startRuntimeCommand, validateApiBase } from './config';
+
+describe('runtime URL config', () => {
+ it('defaults to the runtime port 4380 on loopback', () => {
+ expect(DEFAULT_RUNTIME_PORT).toBe(4380);
+ if (!process.env.NEXT_PUBLIC_RUNLOOP_API) expect(DEFAULT_API_BASE).toBe('http://127.0.0.1:4380');
+ });
+
+ it('applies and clears a viewer override', () => {
+ setApiBaseOverride('http://127.0.0.1:4392/');
+ expect(apiBase()).toBe('http://127.0.0.1:4392');
+ setApiBaseOverride(null);
+ expect(apiBase()).toBe(DEFAULT_API_BASE);
+ });
+
+ it('validates URLs and flags non-loopback hosts', () => {
+ expect(validateApiBase('http://127.0.0.1:4380')).toBeNull();
+ expect(validateApiBase('127.0.0.1:4380')).not.toBeNull();
+ expect(validateApiBase('ftp://127.0.0.1')).not.toBeNull();
+ expect(validateApiBase('http://127.0.0.1:4380/api')).not.toBeNull();
+ expect(validateApiBase('http://user:pw@127.0.0.1:4380')).not.toBeNull();
+ expect(isLoopbackUrl('http://localhost:4380')).toBe(true);
+ expect(isLoopbackUrl('http://[::1]:4380')).toBe(true);
+ expect(isLoopbackUrl('http://10.0.0.2:4380')).toBe(false);
+ });
+
+ it('gives the exact start command for the configured port', () => {
+ expect(startRuntimeCommand('http://127.0.0.1:4380')).toBe('pnpm serve');
+ expect(startRuntimeCommand('http://127.0.0.1:4392')).toBe('pnpm serve -- --port 4392');
+ });
+});
+
+describe('Claude Code plugin commands', () => {
+ it('point the plugin at this Studio’s runtime only when it is not the default', async () => {
+ const { connectCommands, pluginUrlPrefix } = await import('./claude-code');
+ expect(pluginUrlPrefix('http://127.0.0.1:4380/')).toBe('');
+ expect(pluginUrlPrefix('http://127.0.0.1:4394')).toBe('RUNLOOP_URL=http://127.0.0.1:4394 ');
+ expect(connectCommands('http://127.0.0.1:4380')).toEqual({
+ serve: 'pnpm serve',
+ claude: 'claude --plugin-dir ./integrations/claude-code-plugin',
+ doctor: 'node ./integrations/claude-code-plugin/scripts/runloop-hook.mjs --doctor',
+ });
+ expect(connectCommands('http://127.0.0.1:4394').claude).toBe('RUNLOOP_URL=http://127.0.0.1:4394 claude --plugin-dir ./integrations/claude-code-plugin');
+ });
+});
diff --git a/apps/studio/src/lib/config.ts b/apps/studio/src/lib/config.ts
new file mode 100644
index 0000000..4bc1ba9
--- /dev/null
+++ b/apps/studio/src/lib/config.ts
@@ -0,0 +1,74 @@
+import { DEFAULT_RUNTIME_PORT } from '@runloop-world/events';
+
+/** Runtime port when nothing overrides it (4380, DL-005: moved off the OTLP port 4317). */
+export { DEFAULT_RUNTIME_PORT };
+
+/** Base URL of the local runtime API baked in at build time (no trailing slash). */
+export const DEFAULT_API_BASE = normalizeApiBase(process.env.NEXT_PUBLIC_RUNLOOP_API ?? `http://127.0.0.1:${DEFAULT_RUNTIME_PORT}`);
+
+let override: string | null = null;
+
+/** The runtime URL in use: the viewer's override from Settings, else the build default. */
+export function apiBase(): string {
+ return override ?? DEFAULT_API_BASE;
+}
+
+/** Set (or clear with null) the viewer's runtime URL override. */
+export function setApiBaseOverride(url: string | null): void {
+ override = url ? normalizeApiBase(url) : null;
+}
+
+export function normalizeApiBase(url: string): string {
+ return url.trim().replace(/\/+$/, '');
+}
+
+/** A valid http(s) URL with no path, query or credentials; returns an error message or null. */
+export function validateApiBase(url: string): string | null {
+ let u: URL;
+ try {
+ u = new URL(url.trim());
+ } catch {
+ return 'Enter a full URL, for example http://127.0.0.1:4380';
+ }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'Use http:// or https://';
+ if (u.username || u.password) return 'Credentials are not allowed in the URL';
+ if ((u.pathname !== '/' && u.pathname !== '') || u.search || u.hash) return 'Use the server address only, without a path';
+ return null;
+}
+
+/** The runtime only listens on loopback; anything else is worth a warning. */
+export function isLoopbackUrl(url: string): boolean {
+ try {
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, '');
+ return host === 'localhost' || host === '::1' || /^127\./.test(host);
+ } catch {
+ return false;
+ }
+}
+
+/** Port of a runtime URL (the scheme default when none is given). */
+export function portOf(url: string): number | null {
+ try {
+ const u = new URL(url);
+ if (u.port) return Number(u.port);
+ return u.protocol === 'https:' ? 443 : 80;
+ } catch {
+ return null;
+ }
+}
+
+/** Mission started by the "Run demo mission" button. */
+export const DEMO_MISSION_ID = 'coding-mission';
+
+/**
+ * Command that starts the runtime, run from the repo root. Runtime only: when
+ * this hint is visible the Studio is already being served, so `pnpm dev`
+ * would hit EADDRINUSE. A non-default port is passed explicitly.
+ */
+export function startRuntimeCommand(url: string = apiBase()): string {
+ const port = portOf(url);
+ return port === null || port === DEFAULT_RUNTIME_PORT ? 'pnpm serve' : `pnpm serve -- --port ${port}`;
+}
+
+/** Browser origins the runtime accepts by default (packages/runtime/src/config.ts). */
+export const DEFAULT_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000'];
diff --git a/apps/studio/src/scene/Board.tsx b/apps/studio/src/scene/Board.tsx
new file mode 100644
index 0000000..c1f10e5
--- /dev/null
+++ b/apps/studio/src/scene/Board.tsx
@@ -0,0 +1,321 @@
+'use client';
+import { useFrame } from '@react-three/fiber';
+import type { RunExecStatus } from '@runloop-world/core';
+import type { Zone } from '@runloop-world/world-engine';
+import { memo, useLayoutEffect, useMemo, useRef } from 'react';
+import { Color, Object3D, Vector3, type InstancedMesh, type MeshBasicMaterial } from 'three';
+import { useScene } from './context';
+import { useLabel } from './labels';
+import { BACK_WALL_H, CURB_H, FLOOR_Y, ROOM_Y, cellNoise, classifyWalls, floorTiles, isWall, toScene, zoneSignCell } from './layout';
+import { desaturate, mixHex, stateGlow, zoneAccent, zoneCap, zoneFloor, type ScenePalette } from './palette';
+
+const tmp = new Object3D();
+const tmpColor = new Color();
+
+/**
+ * The diorama: an obsidian base slab, graphite floor tiles, rooms raised as
+ * quietly tinted platforms, cutaway walls in slate, and the Atrium core whose
+ * ring shows the run's status. Nothing here glows except that ring.
+ */
+export const Board = memo(function Board() {
+ const { world, palette } = useScene();
+ const w = world.width + 1.6;
+ const d = world.height + 1.6;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+/** A hairline lip around the slab: tonal, not emissive. */
+function Edge({ w, d, palette }: { w: number; d: number; palette: ScenePalette }) {
+ const t = 0.05;
+ const bars: [number, number, number, number][] = [
+ [0, -d / 2, w, t],
+ [0, d / 2, w, t],
+ [-w / 2, 0, t, d],
+ [w / 2, 0, t, d],
+ ];
+ return (
+
+ {bars.map(([x, z, sx, sz], i) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** Every floor cell as one instanced tile with a barely visible per-tile shade. */
+function Tiles() {
+ const { world, palette } = useScene();
+ const tiles = useMemo(() => floorTiles(world), [world]);
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const mesh = ref.current;
+ if (!mesh) return;
+ const floors = new Map();
+ for (const z of world.def.zones) floors.set(z.id, zoneFloor(palette, z.color));
+ const shade = palette.theme === 'light' ? palette.tokens.slateHi : palette.tokens.slate;
+ tiles.forEach((t, i) => {
+ const [x, , z] = toScene(world, t.cell[0], t.cell[1]);
+ const room = t.zone && t.zone.kind !== 'hub';
+ const h = room ? 0.05 : t.top;
+ tmp.position.set(x, t.top - h / 2, z);
+ tmp.scale.set(0.95, h, 0.95);
+ tmp.updateMatrix();
+ mesh.setMatrixAt(i, tmp.matrix);
+ const n = cellNoise(t.cell[0], t.cell[1]);
+ const base = room ? floors.get(t.zone!.id)! : palette.corridor;
+ tmpColor.set(mixHex(base, shade, n * 0.22));
+ mesh.setColorAt(i, tmpColor);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
+ mesh.computeBoundingSphere();
+ }, [tiles, world, palette]);
+ return (
+
+
+
+
+ );
+}
+
+/** Each room is a slightly raised platform under its tiles. */
+function RoomPlatforms() {
+ const { world, palette } = useScene();
+ const rooms = world.def.zones.filter((z) => z.kind !== 'hub');
+ return (
+
+ {rooms.map((z) => {
+ const [rx, ry, rw, rh] = z.rect;
+ const [cx, , cz] = toScene(world, rx + (rw - 1) / 2, ry + (rh - 1) / 2);
+ return (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Walls: back (north/west) room walls are tall, front (south/east) walls are
+ * low curbs so the inside stays visible. A thin cap in the room's muted tint
+ * runs along the tops, and back walls carry dark glass panels.
+ */
+function Walls() {
+ const { world, palette } = useScene();
+ const walls = useMemo(() => classifyWalls(world), [world]);
+ const blocks = useRef(null);
+ const caps = useRef(null);
+ const panels = useRef(null);
+
+ const panelInst = useMemo(() => {
+ const out: { pos: [number, number, number]; rotY: number; color: string; wide: boolean }[] = [];
+ for (const wc of walls) {
+ if (wc.kind !== 'back' || !wc.zone) continue;
+ const [gx, gy] = wc.cell;
+ const [x, , z] = toScene(world, gx, gy);
+ const [rx, ry, rw, rh] = wc.zone.rect;
+ for (const [dx, dy] of [
+ [0, 1],
+ [1, 0],
+ ] as const) {
+ const nx = gx + dx;
+ const ny = gy + dy;
+ const inside = nx > rx && ny > ry && nx < rx + rw - 1 && ny < ry + rh - 1;
+ if (!inside || isWall(world, nx, ny)) continue;
+ const along = dx ? gy : gx;
+ if (along % 2 !== 0) continue;
+ out.push({ pos: [x + dx * 0.505, ROOM_Y + 0.8, z + dy * 0.505], rotY: dx ? Math.PI / 2 : 0, color: wc.zone.color, wide: along % 4 === 0 });
+ }
+ }
+ return out;
+ }, [walls, world]);
+
+ useLayoutEffect(() => {
+ const b = blocks.current;
+ const c = caps.current;
+ if (b && c) {
+ walls.forEach((wc, i) => {
+ const [x, , z] = toScene(world, wc.cell[0], wc.cell[1]);
+ const h = wc.kind === 'back' ? ROOM_Y + BACK_WALL_H : ROOM_Y + CURB_H;
+ tmp.rotation.set(0, 0, 0);
+ tmp.position.set(x, h / 2, z);
+ tmp.scale.set(1, h, 1);
+ tmp.updateMatrix();
+ b.setMatrixAt(i, tmp.matrix);
+ const n = cellNoise(wc.cell[0] + 7, wc.cell[1] + 3);
+ tmpColor.set(mixHex(wc.kind === 'back' ? palette.wall : palette.curb, palette.tokens.slateHi, n * 0.25));
+ b.setColorAt(i, tmpColor);
+ tmp.position.set(x, h + 0.02, z);
+ tmp.scale.set(1.002, 0.04, 1.002);
+ tmp.updateMatrix();
+ c.setMatrixAt(i, tmp.matrix);
+ tmpColor.set(wc.zone ? zoneCap(palette, wc.zone.color) : palette.wallCap);
+ c.setColorAt(i, tmpColor);
+ });
+ for (const m of [b, c]) {
+ m.instanceMatrix.needsUpdate = true;
+ if (m.instanceColor) m.instanceColor.needsUpdate = true;
+ m.computeBoundingSphere();
+ }
+ }
+ const p = panels.current;
+ if (p) {
+ panelInst.forEach((pi, i) => {
+ tmp.position.set(...pi.pos);
+ tmp.rotation.set(0, pi.rotY, 0);
+ tmp.scale.set(pi.wide ? 0.78 : 0.5, pi.wide ? 0.42 : 0.62, 1);
+ tmp.updateMatrix();
+ p.setMatrixAt(i, tmp.matrix);
+ tmpColor.set(mixHex(palette.screenOff, desaturate(pi.color, 0.4), pi.wide ? 0.14 : 0.08));
+ p.setColorAt(i, tmpColor);
+ });
+ p.instanceMatrix.needsUpdate = true;
+ if (p.instanceColor) p.instanceColor.needsUpdate = true;
+ p.computeBoundingSphere();
+ }
+ }, [walls, world, palette, panelInst]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {panelInst.length > 0 && (
+
+
+
+
+ )}
+
+ );
+}
+
+/** Run status → colour of the Atrium ring. Lime only while the run executes. */
+function ringColor(p: ScenePalette, status: RunExecStatus): { color: string; glow: boolean } {
+ switch (status) {
+ case 'running':
+ return { color: p.active, glow: true };
+ case 'succeeded':
+ return { color: p.success, glow: true };
+ case 'failed':
+ return { color: p.failure, glow: true };
+ case 'paused':
+ case 'stopped':
+ case 'interrupted':
+ return { color: p.warning, glow: false };
+ default:
+ return { color: p.muted, glow: false };
+ }
+}
+
+/**
+ * The Atrium: a low pedestal on the core cells with a dark monolith. Its ring
+ * is the only state on the board itself: the run's status (lime while it runs).
+ */
+function HubCore() {
+ const { world, palette, getFrame, reducedMotion } = useScene();
+ const hub = world.def.hub;
+ const [x, , z] = toScene(world, hub.center[0], hub.center[1]);
+ const xs = hub.core.map((c) => c[0]);
+ const ys = hub.core.map((c) => c[1]);
+ const span = hub.core.length ? Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) + 1 : 1.5;
+ const r = span * 0.46;
+ const ring = useRef(null);
+ const status = useRef(null);
+ const base = useRef(new Color());
+
+ useFrame(({ clock }) => {
+ const m = ring.current;
+ if (!m) return;
+ const s = getFrame().state.exec.run.status;
+ if (s !== status.current) {
+ status.current = s;
+ const c = ringColor(palette, s);
+ base.current.copy(c.glow ? stateGlow(palette, c.color, 1.5) : new Color(c.color));
+ }
+ // A slow, quiet breath while the run executes (a state, not decoration); still under reduced motion.
+ const k = s === 'running' && !reducedMotion ? 0.85 + 0.15 * Math.sin(clock.elapsedTime * 1.6) : 1;
+ m.color.copy(base.current).multiplyScalar(k);
+ });
+
+ const top = palette.metal;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Monolith: a quiet obsidian column with a lighter cap. */}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Environment names from the world definition, as signs at each room's outer corner. */
+function ZoneSigns() {
+ const { world } = useScene();
+ return (
+ <>
+ {world.def.zones.map((z) => (
+
+ ))}
+ >
+ );
+}
+
+function ZoneSign({ zone }: { zone: Zone }) {
+ const { world, palette } = useScene();
+ const anchor = useMemo(() => {
+ const [gx, gy] = zoneSignCell(world, zone);
+ if (zone.kind === 'hub') return new Vector3(...toScene(world, gx, gy, FLOOR_Y));
+ // On top of the corner wall: tall at the back (north/west) corners, a curb at the front.
+ const [rx, ry] = zone.rect;
+ const back = Math.round(gx + 0.5) === rx || Math.round(gy + 0.5) === ry;
+ return new Vector3(...toScene(world, gx, gy, ROOM_Y + (back ? BACK_WALL_H : CURB_H) + 0.05));
+ }, [world, zone]);
+ useLabel(`zone:${zone.id}`, { kind: 'zone', text: zone.label, color: zoneAccent(palette, zone.color) }, anchor);
+ return null;
+}
diff --git a/apps/studio/src/scene/CameraRig.tsx b/apps/studio/src/scene/CameraRig.tsx
new file mode 100644
index 0000000..058a52b
--- /dev/null
+++ b/apps/studio/src/scene/CameraRig.tsx
@@ -0,0 +1,254 @@
+'use client';
+import { MapControls } from '@react-three/drei';
+import { useFrame, useThree } from '@react-three/fiber';
+import { useCallback, useEffect, useLayoutEffect, useRef, type ComponentRef } from 'react';
+import { MathUtils, Vector3, type OrthographicCamera } from 'three';
+import { poseAt, useScene, type CameraRequest } from './context';
+import { ISO_DIR, TOP_DIR, easeOut, frameBoard, parseDurationMs } from './framing';
+import { cellTop, heightAt, toScene } from './layout';
+import type { CameraMode } from './types';
+
+/** The two views as spherical angles, so transitions tilt and turn smoothly (no spin near the pole). */
+const ANGLES = {
+ iso: { azimuth: Math.atan2(ISO_DIR.x, ISO_DIR.z), polar: Math.acos(ISO_DIR.y) },
+ top: { azimuth: 0, polar: Math.acos(TOP_DIR.y) },
+} as const;
+type Angles = { azimuth: number; polar: number };
+const fromAngles = (out: Vector3, a: Angles) =>
+ out.set(Math.sin(a.polar) * Math.sin(a.azimuth), Math.cos(a.polar), Math.sin(a.polar) * Math.cos(a.azimuth));
+const DIST = 80;
+/** Zoom relative to the fitted board when focusing one entity, and when following. */
+const FOCUS_ZOOM = 2.3;
+const FOLLOW_ZOOM = 1.8;
+
+/** overview: the whole board, re-fitted on resize. free: the viewer (or a focus) set the view. follow: tracking the selected agent. */
+type Mode = 'overview' | 'free' | 'follow';
+
+interface Tween {
+ fromTarget: Vector3;
+ toTarget: Vector3;
+ fromZoom: number;
+ toZoom: number;
+ fromAngles: Angles;
+ toAngles: Angles;
+ start: number;
+ duration: number;
+}
+
+/** Camera move duration from the motion token (0 under reduced motion, where the tokens are zeroed). */
+function cameraDurationMs(reducedMotion: boolean): number {
+ if (reducedMotion || typeof document === 'undefined') return 0;
+ return parseDurationMs(getComputedStyle(document.documentElement).getPropertyValue('--rl-dur-camera'), 550);
+}
+
+/**
+ * Orthographic camera: isometric or top-down, pan/zoom with MapControls
+ * (rotation disabled). Fits the whole board to the canvas (any aspect, with
+ * room for the floating controls) and keeps it fitted on resize until the
+ * viewer takes over. Presets (overview, focus, follow) ease with the camera
+ * motion token and snap under reduced motion.
+ */
+export function CameraRig({
+ cameraMode,
+ follow,
+ selectedAgentId,
+ selectedStationId,
+}: {
+ cameraMode: CameraMode;
+ follow: boolean;
+ selectedAgentId: string | null;
+ selectedStationId: string | null;
+}) {
+ const { world, getFrame, reducedMotion, camera: bus } = useScene();
+ const controls = useRef>(null);
+ const camera = useThree((s) => s.camera) as OrthographicCamera;
+ const size = useThree((s) => s.size);
+ const angles = useRef({ ...ANGLES[cameraMode] });
+ const dir = useRef(fromAngles(new Vector3(), angles.current));
+ const tween = useRef(null);
+ const mode = useRef('overview');
+ const fitZoom = useRef(1);
+ const placed = useRef(false);
+ const tmp = useRef(new Vector3()).current;
+
+ const setMode = useCallback(
+ (m: Mode) => {
+ mode.current = m;
+ bus.setOverview(m === 'overview');
+ },
+ [bus],
+ );
+
+ /** Start an eased move to `target`/`zoom` (and the view direction of `toMode`). */
+ const moveTo = useCallback(
+ (target: Vector3, zoom: number, toMode: CameraMode = cameraMode) => {
+ const c = controls.current;
+ if (!c) return;
+ tween.current = {
+ fromTarget: c.target.clone(),
+ toTarget: target.clone(),
+ fromZoom: camera.zoom,
+ toZoom: zoom,
+ fromAngles: { ...angles.current },
+ toAngles: { ...ANGLES[toMode] },
+ start: performance.now(),
+ duration: cameraDurationMs(reducedMotion),
+ };
+ },
+ [camera, cameraMode, reducedMotion],
+ );
+
+ /** Where an entity is: an agent's current position, else a station. */
+ const entityPoint = useCallback(
+ (agentId: string | null, stationId: string | null): Vector3 | null => {
+ if (agentId) {
+ const pose = poseAt(world, getFrame(), agentId);
+ if (pose) return new Vector3(...toScene(world, pose.x, pose.y, heightAt(world, pose.x, pose.y) + 0.6));
+ }
+ const s = stationId ? world.stationsById[stationId] : undefined;
+ return s ? new Vector3(...toScene(world, s.cell[0], s.cell[1], cellTop(world, s.cell[0], s.cell[1]) + 0.6)) : null;
+ },
+ [world, getFrame],
+ );
+
+ // First layout: frame the board instantly.
+ useLayoutEffect(() => {
+ const c = controls.current;
+ if (!c || placed.current || size.width === 0 || size.height === 0) return;
+ placed.current = true;
+ const f = frameBoard(world, dir.current, size.width, size.height);
+ fitZoom.current = f.zoom;
+ camera.zoom = f.zoom;
+ camera.updateProjectionMatrix();
+ c.target.copy(f.target);
+ camera.position.copy(f.target).addScaledVector(dir.current, DIST);
+ camera.lookAt(f.target);
+ }, [camera, size.width, size.height, world]);
+
+ // Resize: the overview re-fits at once; a custom view keeps its target and scales with the fit.
+ useEffect(() => {
+ if (!placed.current || size.width === 0 || size.height === 0) return;
+ const c = controls.current;
+ if (!c) return;
+ const f = frameBoard(world, fromAngles(new Vector3(), ANGLES[cameraMode]), size.width, size.height);
+ const ratio = f.zoom / fitZoom.current;
+ fitZoom.current = f.zoom;
+ if (mode.current === 'overview') {
+ if (tween.current) {
+ tween.current.toTarget.copy(f.target);
+ tween.current.toZoom = f.zoom;
+ } else {
+ c.target.copy(f.target);
+ camera.zoom = f.zoom;
+ camera.updateProjectionMatrix();
+ }
+ } else {
+ camera.zoom *= ratio;
+ camera.updateProjectionMatrix();
+ if (tween.current) tween.current.toZoom *= ratio;
+ }
+ // Only on size changes.
+ }, [size.width, size.height]);
+
+ // Iso ↔ top: turn the camera; the overview re-fits for the new view.
+ const firstMode = useRef(true);
+ useEffect(() => {
+ if (firstMode.current) {
+ firstMode.current = false;
+ return;
+ }
+ const c = controls.current;
+ if (!c) return;
+ const f = frameBoard(world, fromAngles(new Vector3(), ANGLES[cameraMode]), size.width, size.height);
+ const ratio = f.zoom / fitZoom.current;
+ fitZoom.current = f.zoom;
+ if (mode.current === 'overview') moveTo(f.target, f.zoom, cameraMode);
+ else moveTo(c.target, camera.zoom * ratio, cameraMode);
+ }, [cameraMode]);
+
+ // Presets from the camera rail.
+ useEffect(
+ () =>
+ bus.listen((r: CameraRequest) => {
+ const fit = frameBoard(world, fromAngles(new Vector3(), ANGLES[cameraMode]), size.width, size.height);
+ fitZoom.current = fit.zoom;
+ if (r.kind === 'overview') {
+ setMode('overview');
+ moveTo(fit.target, fit.zoom);
+ return;
+ }
+ const p = r.kind === 'focusAgent' ? entityPoint(r.agentId, null) : entityPoint(null, r.stationId);
+ if (!p) return;
+ setMode('free');
+ moveTo(p, Math.max(camera.zoom, fit.zoom * FOCUS_ZOOM));
+ }),
+ [bus, world, cameraMode, size.width, size.height, moveTo, entityPoint, setMode, camera],
+ );
+
+ // Follow: move in a little closer, then track the selected agent every frame.
+ useEffect(() => {
+ if (follow && selectedAgentId) {
+ setMode('follow');
+ const p = entityPoint(selectedAgentId, null);
+ if (p) moveTo(p, Math.max(camera.zoom, fitZoom.current * FOLLOW_ZOOM));
+ } else if (mode.current === 'follow') {
+ setMode('free');
+ }
+ }, [follow, selectedAgentId]);
+
+ // Selection from the timeline or inspector: when zoomed in, bring the entity into view (the overview already shows it).
+ useEffect(() => {
+ if (mode.current !== 'free') return;
+ const p = entityPoint(selectedAgentId, selectedStationId);
+ if (p) moveTo(p, camera.zoom);
+ }, [selectedAgentId, selectedStationId]);
+
+ // The viewer took over: drop any pending move. (Stable, so drei does not reconnect the controls.)
+ const onUserStart = useCallback(() => {
+ tween.current = null;
+ if (mode.current !== 'follow') setMode('free');
+ }, [setMode]);
+
+ useFrame((_, dt) => {
+ const c = controls.current;
+ if (!c) return;
+ const tw = tween.current;
+ if (tw) {
+ const t = tw.duration <= 0 ? 1 : easeOut(Math.min(1, (performance.now() - tw.start) / tw.duration));
+ angles.current.azimuth = MathUtils.lerp(tw.fromAngles.azimuth, tw.toAngles.azimuth, t);
+ angles.current.polar = MathUtils.lerp(tw.fromAngles.polar, tw.toAngles.polar, t);
+ // While following, the goal moves with the agent.
+ if (mode.current === 'follow' && selectedAgentId) {
+ const p = entityPoint(selectedAgentId, null);
+ if (p) tw.toTarget.copy(p);
+ }
+ c.target.lerpVectors(tw.fromTarget, tw.toTarget, t);
+ camera.zoom = MathUtils.lerp(tw.fromZoom, tw.toZoom, t);
+ camera.updateProjectionMatrix();
+ if (t >= 1) tween.current = null;
+ } else if (mode.current === 'follow' && selectedAgentId) {
+ const pose = poseAt(world, getFrame(), selectedAgentId);
+ if (pose) {
+ tmp.set(...toScene(world, pose.x, pose.y, heightAt(world, pose.x, pose.y) + 0.6));
+ c.target.lerp(tmp, reducedMotion ? 1 : 1 - Math.exp(-dt * 5));
+ }
+ }
+ fromAngles(dir.current, angles.current);
+ camera.position.copy(c.target).addScaledVector(dir.current, DIST);
+ camera.lookAt(c.target);
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/studio/src/scene/Character.tsx b/apps/studio/src/scene/Character.tsx
new file mode 100644
index 0000000..cdfb161
--- /dev/null
+++ b/apps/studio/src/scene/Character.tsx
@@ -0,0 +1,512 @@
+'use client';
+import { RoundedBox } from '@react-three/drei';
+import { flushSync, useFrame, type ThreeEvent } from '@react-three/fiber';
+import type { RunState } from '@runloop-world/replay';
+import type { PresentationState } from '@runloop-world/world-engine';
+import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { Color, MathUtils, MeshBasicMaterial, type Group, type Mesh, type Vector3 } from 'three';
+import { identityColor } from '../state/identity';
+import { badgeDetail, compactDetail, presetIcon, usageLine } from './badges';
+import { poseAt, useScene } from './context';
+import { useLabel } from './labels';
+import { heightAt, toScene, yawForHeading } from './layout';
+import { identityHex, mix, mixHex, stateGlow, type ScenePalette } from './palette';
+import { blobShadowTexture } from './textures';
+
+/** Model scale: a chibi robot ~2.2 units tall to the antenna tip (1 cell = 1 unit). */
+const SCALE = 2;
+const BADGE_Y = 2.2;
+
+interface View {
+ state: PresentationState;
+ detail: string;
+ short: string;
+ usage: string | null;
+ /** A finished subagent: kept in place, quieter. */
+ dim: boolean;
+}
+
+function viewOf(state: PresentationState, agentId: string, run: RunState, world: Parameters[3]): View {
+ return {
+ state,
+ detail: badgeDetail(state, agentId, run, world),
+ short: compactDetail(state, agentId, run),
+ usage: usageLine(run, agentId),
+ dim: run.exec.agents[agentId]?.state === 'done' && run.exec.run.status === 'running',
+ };
+}
+
+const sameView = (a: View, b: View) => a.state === b.state && a.detail === b.detail && a.short === b.short && a.usage === b.usage && a.dim === b.dim;
+
+/** The floor ring under a robot: what state it is in (semantic colour; lime only while working). */
+function ringFor(p: ScenePalette, state: PresentationState, walking: boolean): Color | null {
+ if (walking) return null;
+ switch (state) {
+ case 'working':
+ return stateGlow(p, p.active, 1.6);
+ case 'failed':
+ return stateGlow(p, p.failure, 1.5);
+ case 'awaiting_approval':
+ case 'waiting':
+ return stateGlow(p, p.warning, 1.3);
+ case 'succeeded':
+ return stateGlow(p, p.success, 1.4);
+ default:
+ return null;
+ }
+}
+
+/** Eye colour: the eyes carry state (their shape too), so they may glow. */
+function eyeColor(p: ScenePalette, state: PresentationState, dim: boolean): Color {
+ if (dim || state === 'paused') return new Color(mixHex(p.visor, p.muted, 0.6));
+ switch (state) {
+ case 'working':
+ return stateGlow(p, p.active, 1.7);
+ case 'failed':
+ return stateGlow(p, p.failure, 1.8);
+ case 'awaiting_approval':
+ case 'waiting':
+ return stateGlow(p, p.warning, 1.6);
+ case 'succeeded':
+ return stateGlow(p, p.success, 1.6);
+ default:
+ return p.theme === 'light' ? new Color(p.tokens.graphite) : stateGlow(p, p.ice, 1.3);
+ }
+}
+
+/**
+ * One character. Where it is and what state it shows come only from Layer 2
+ * (presentationAt at the frame's tick); bobbing, typing and blinking are
+ * Layer-3 decoration on top. Its colour is the agent's identity hue (the same
+ * token the timeline and Network view use). Re-renders only when the badge changes.
+ */
+export const Character = memo(function Character({
+ agentId,
+ selected,
+ onSelect,
+}: {
+ agentId: string;
+ selected: boolean;
+ onSelect: (id: string | null) => void;
+}) {
+ const { world, getFrame, reducedMotion, palette, labels } = useScene();
+ const info = useMemo(() => {
+ const frame = getFrame();
+ const c = frame.state.world.characters[agentId];
+ const preset = (c && world.presetsById[c.presetId]) || world.presetsById[world.def.defaultPreset]!;
+ const exec = frame.state.exec;
+ const name = exec.agents[agentId]?.name ?? agentId;
+ // Per-agent phase so blinks and bobs are not in lockstep.
+ let phase = 0;
+ for (const ch of agentId) phase = (phase * 31 + ch.charCodeAt(0)) % 997;
+ return { preset, name, hex: identityHex(palette, exec, agentId), css: identityColor(exec, agentId), phase: (phase / 997) * Math.PI * 2 };
+ }, [agentId, getFrame, world, palette]);
+ const color = info.hex;
+
+ const [view, setView] = useState(() => {
+ const frame = getFrame();
+ const pose = poseAt(world, frame, agentId);
+ return viewOf(pose?.state ?? 'idle', agentId, frame.state, world);
+ });
+ const viewRef = useRef(view);
+ viewRef.current = view;
+
+ const root = useRef(null);
+ const bounce = useRef(null);
+ const body = useRef(null);
+ const head = useRef(null);
+ const eyes = useRef(null);
+ const armL = useRef(null);
+ const armR = useRef(null);
+ const legL = useRef(null);
+ const legR = useRef(null);
+ const shadow = useRef(null);
+ const select = useRef(null);
+ const stateRing = useRef(null);
+ const stateMat = useMemo(() => new MeshBasicMaterial({ toneMapped: false, transparent: true, opacity: 0.95 }), []);
+ const dots = useRef<(Mesh | null)[]>([]);
+ const yaw = useRef(null);
+ const ringState = useRef('');
+
+ // One shared material for every eye shape; its colour follows the state.
+ const eyeMat = useMemo(() => new MeshBasicMaterial({ toneMapped: false }), []);
+ useEffect(
+ () => () => {
+ eyeMat.dispose();
+ stateMat.dispose();
+ },
+ [eyeMat, stateMat],
+ );
+ useLayoutEffect(() => {
+ eyeMat.color.copy(eyeColor(palette, view.state, view.dim));
+ }, [eyeMat, view.state, view.dim, palette]);
+
+ useFrame(({ clock }, dt) => {
+ const frame = getFrame();
+ const pose = poseAt(world, frame, agentId);
+ const g = root.current;
+ if (!pose || !g) return;
+
+ // Layer 2: position + heading.
+ const [x, , z] = toScene(world, pose.x, pose.y);
+ g.position.set(x, heightAt(world, pose.x, pose.y), z);
+ const targetYaw = yawForHeading(pose.heading);
+ if (yaw.current === null || reducedMotion) yaw.current = targetYaw;
+ else {
+ const diff = Math.atan2(Math.sin(targetYaw - yaw.current), Math.cos(targetYaw - yaw.current));
+ yaw.current += diff * (1 - Math.exp(-dt * 14));
+ }
+ g.rotation.y = yaw.current;
+
+ // React only hears about badge changes. They are rare, so commit them
+ // synchronously: the badge then paints in the same frame as this pose.
+ const next = viewOf(pose.state, agentId, frame.state, world);
+ if (!sameView(next, viewRef.current)) {
+ viewRef.current = next;
+ flushSync(() => setView(next));
+ }
+
+ const walking = pose.moving && frame.advancing;
+ // State ring (only its colour/visibility changes; a quiet breath while working).
+ const rk = `${pose.state}|${walking}`;
+ if (rk !== ringState.current) {
+ ringState.current = rk;
+ const c = ringFor(palette, pose.state, walking);
+ if (stateRing.current) stateRing.current.visible = c !== null;
+ if (c) stateMat.color.copy(c);
+ }
+ if (pose.state === 'working' && !walking && !reducedMotion) stateMat.opacity = 0.75 + 0.2 * Math.sin(clock.elapsedTime * 4 + info.phase);
+ else stateMat.opacity = 0.95;
+
+ // Layer 3 decoration.
+ const t = clock.elapsedTime + info.phase;
+ const k = reducedMotion ? 1 : 1 - Math.exp(-dt * 16);
+ animate(pose.state, walking, t, k, reducedMotion);
+ if (select.current) select.current.rotation.z = reducedMotion ? 0 : clock.elapsedTime * 0.6;
+ });
+
+ function animate(state: PresentationState, walking: boolean, t: number, k: number, still: boolean) {
+ const b = body.current;
+ const h = head.current;
+ const aL = armL.current;
+ const aR = armR.current;
+ const lL = legL.current;
+ const lR = legR.current;
+ const bo = bounce.current;
+ if (!b || !h || !aL || !aR || !lL || !lR || !bo) return;
+ // Targets (defaults = relaxed standing pose).
+ let bodyY = 0;
+ let bodyTiltZ = 0;
+ let bodyTiltX = 0;
+ let headX = 0;
+ let headY = 0;
+ let headZ = 0;
+ let armLX = 0;
+ let armRX = 0;
+ let legLX = 0;
+ let legRX = 0;
+ let hop = 0;
+ let blink = 1;
+ const loop = !still;
+
+ if (walking && loop) {
+ const c = t * 11;
+ legLX = Math.sin(c) * 0.6;
+ legRX = -legLX;
+ armLX = -Math.sin(c) * 0.55;
+ armRX = -armLX;
+ bodyY = Math.abs(Math.sin(c)) * 0.035;
+ bodyTiltZ = Math.sin(c) * 0.04;
+ bodyTiltX = 0.06;
+ } else {
+ switch (state) {
+ case 'working':
+ armLX = -1.05 + (loop ? Math.sin(t * 16) * 0.2 : 0);
+ armRX = -1.05 + (loop ? Math.sin(t * 16 + Math.PI) * 0.2 : 0);
+ headX = 0.14 + (loop ? Math.sin(t * 4) * 0.035 : 0);
+ bodyTiltX = 0.07;
+ break;
+ case 'succeeded':
+ hop = loop ? Math.max(0, Math.sin(t * 7)) * 0.1 : 0;
+ armLX = -2.5 + (loop ? Math.sin(t * 7) * 0.25 : 0);
+ armRX = armLX;
+ headX = -0.12;
+ break;
+ case 'failed':
+ bodyTiltZ = 0.22;
+ headX = 0.32;
+ headZ = 0.14;
+ armLX = 0.12;
+ armRX = 0.12;
+ break;
+ case 'waiting':
+ armLX = -0.45;
+ armRX = -0.45;
+ headZ = loop ? Math.sin(t * 1.2) * 0.08 : 0.06;
+ break;
+ case 'awaiting_approval':
+ headZ = 0.2 + (loop ? Math.sin(t * 1.5) * 0.04 : 0);
+ armRX = -2.6;
+ break;
+ case 'paused':
+ headX = 0.08;
+ break;
+ default:
+ // Idle stays restrained: a slow glance and an occasional blink.
+ if (loop) {
+ headY = Math.sin(t * 0.5) * 0.12;
+ blink = t % 4.3 < 0.12 ? 0.12 : 1;
+ }
+ }
+ }
+ const L = MathUtils.lerp;
+ b.position.y = L(b.position.y, bodyY, k);
+ b.rotation.z = L(b.rotation.z, bodyTiltZ, k);
+ b.rotation.x = L(b.rotation.x, bodyTiltX, k);
+ h.rotation.x = L(h.rotation.x, headX, k);
+ h.rotation.y = L(h.rotation.y, headY, k);
+ h.rotation.z = L(h.rotation.z, headZ, k);
+ aL.rotation.x = L(aL.rotation.x, armLX, k);
+ aR.rotation.x = L(aR.rotation.x, armRX, k);
+ lL.rotation.x = L(lL.rotation.x, legLX, k);
+ lR.rotation.x = L(lR.rotation.x, legRX, k);
+ bo.position.y = hop;
+ if (shadow.current) shadow.current.scale.setScalar(1 - hop * 1.6);
+ if (eyes.current) eyes.current.scale.y = blink;
+ dots.current.forEach((d, i) => {
+ if (d) d.scale.setScalar(loop ? 0.7 + 0.5 * Math.max(0, Math.sin(t * 5 - i * 0.9)) : 1);
+ });
+ }
+
+ // Badge: follows the Layer-2 position (evaluated by the label projector each frame).
+ const anchor = useMemo(
+ () => (out: Vector3) => {
+ const pose = poseAt(world, getFrame(), agentId);
+ if (!pose) return null;
+ const [x, , z] = toScene(world, pose.x, pose.y);
+ return out.set(x, heightAt(world, pose.x, pose.y) + BADGE_Y, z);
+ },
+ [world, getFrame, agentId],
+ );
+ useLabel(
+ `agent:${agentId}`,
+ {
+ kind: 'agent',
+ agentId,
+ name: info.name,
+ color: info.css,
+ icon: presetIcon(info.preset.icon),
+ state: view.state,
+ detail: view.detail,
+ short: view.short,
+ usage: view.usage,
+ selected,
+ dim: view.dim,
+ },
+ anchor,
+ );
+
+ const onClick = (e: ThreeEvent) => {
+ e.stopPropagation();
+ onSelect(agentId);
+ };
+ const shell = view.dim ? mixHex(palette.robotShell, palette.muted, 0.45) : palette.robotShell;
+ const accent = useMemo(() => (view.dim ? mix(color, palette.muted, 0.6) : new Color(color)), [color, palette, view.dim]);
+ const torso = useMemo(() => mix(palette.robotShell, color, view.dim ? 0.25 : 0.85), [palette, color, view.dim]);
+
+ return (
+
+
+
+
+
+
+
+
+ {selected && (
+
+
+
+
+ )}
+ {
+ e.stopPropagation();
+ labels.hovered = `agent:${agentId}`;
+ document.body.style.cursor = 'pointer';
+ }}
+ onPointerOut={() => {
+ if (labels.hovered === `agent:${agentId}`) labels.hovered = null;
+ document.body.style.cursor = '';
+ }}
+ >
+ {/* Invisible, generous hit box for clicking. */}
+
+
+
+
+ {/* Legs */}
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* Arms */}
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Head */}
+
+
+
+
+ {/* Wrap-around visor: pokes through the head's rounded front corners so it reads from the side too. */}
+
+
+
+
+
+
+ {/* Ears and antenna tip carry the identity hue (identity never signals state). */}
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+/** Eye shapes per presentation state (state carried by shape and colour). */
+function Eyes({
+ state,
+ mat,
+ dots,
+}: {
+ state: PresentationState;
+ mat: MeshBasicMaterial;
+ dots: React.RefObject<(Mesh | null)[]>;
+}) {
+ switch (state) {
+ case 'working':
+ // Focus slits.
+ return (
+ <>
+ {[-1, 1].map((s) => (
+
+
+
+ ))}
+ >
+ );
+ case 'succeeded':
+ // Happy arcs ^ ^.
+ return (
+ <>
+ {[-1, 1].map((s) => (
+
+
+
+ ))}
+ >
+ );
+ case 'failed':
+ // X X.
+ return (
+ <>
+ {[-1, 1].map((s) =>
+ [1, -1].map((r) => (
+
+
+
+ )),
+ )}
+ >
+ );
+ case 'waiting':
+ // "..."
+ return (
+ <>
+ {[-1, 0, 1].map((s, i) => (
+ {
+ dots.current[i] = m;
+ }}
+ position={[s * 0.085, -0.01, 0]}
+ material={mat}
+ >
+
+
+ ))}
+ >
+ );
+ case 'awaiting_approval':
+ // "?"
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ default:
+ // idle / moving / paused: round pill eyes (dim when paused).
+ return (
+ <>
+ {[-1, 1].map((s) => (
+
+
+
+ ))}
+ >
+ );
+ }
+}
diff --git a/apps/studio/src/scene/Characters.tsx b/apps/studio/src/scene/Characters.tsx
new file mode 100644
index 0000000..fcdf431
--- /dev/null
+++ b/apps/studio/src/scene/Characters.tsx
@@ -0,0 +1,35 @@
+'use client';
+import { useFrame } from '@react-three/fiber';
+import { useRef, useState } from 'react';
+import { Character } from './Character';
+import { useScene } from './context';
+
+/** Mounts one per Layer-2 character; re-renders only when that list changes. */
+export function Characters({
+ selectedAgentId,
+ onSelectAgent,
+}: {
+ selectedAgentId: string | null;
+ onSelectAgent: (id: string | null) => void;
+}) {
+ const { getFrame } = useScene();
+ const [ids, setIds] = useState(() => getFrame().state.world.characterOrder);
+ const key = useRef(ids.join('|'));
+
+ useFrame(() => {
+ const order = getFrame().state.world.characterOrder;
+ const k = order.join('|');
+ if (k !== key.current) {
+ key.current = k;
+ setIds(order);
+ }
+ });
+
+ return (
+
+ {ids.map((id) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/studio/src/scene/Effects.tsx b/apps/studio/src/scene/Effects.tsx
new file mode 100644
index 0000000..349f95e
--- /dev/null
+++ b/apps/studio/src/scene/Effects.tsx
@@ -0,0 +1,429 @@
+'use client';
+import { useFrame } from '@react-three/fiber';
+import type { Incident } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent } from '@runloop-world/events';
+import { bfs, type Cell } from '@runloop-world/world-engine';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { AdditiveBlending, Color, NormalBlending, Vector3, type Group, type Mesh, type MeshBasicMaterial } from 'three';
+import { poseAt, stationStatuses, useScene, type SceneCtx } from './context';
+import { useLabel } from './labels';
+import { cellTop, heightAt, hubRing, incidentStationId, pathPoints, toScene } from './layout';
+import { identityHex, stateGlow } from './palette';
+import { pointAlong, polylineLength, ribbonGeometry } from './textures';
+import type { SceneFrame } from './types';
+
+type P3 = [number, number, number];
+
+/** `born` is performance.now() when the effect was queued; its age is wall-clock time since then. */
+type Fx =
+ | { id: number; born: number; kind: 'packet'; pts: P3[]; color: string }
+ | { id: number; born: number; kind: 'burst'; pos: P3; ok: boolean }
+ | { id: number; born: number; kind: 'artifact'; pos: P3; color: string };
+
+let nextId = 1;
+
+/**
+ * Seconds since the effect was queued. Measured on the wall clock (not from
+ * its first frame), so effects queued while frames were paused (background
+ * tab) expire instead of all playing at once when frames resume.
+ */
+const ageOf = (fx: Fx) => (performance.now() - fx.born) / 1000;
+
+/**
+ * Transient Layer-3 effects for events that just became visible (message
+ * packets along the real path between sender and receiver, tool outcome
+ * pulses, artifact pops) plus persistent incident beacons derived from
+ * Layer-1 incidents. Each one marks a recorded event; none is decorative.
+ */
+export function Effects({ selectedSeq }: { selectedSeq: number | null }) {
+ const ctx = useScene();
+ const [items, setItems] = useState([]);
+
+ useEffect(
+ () =>
+ ctx.subscribeEffects((events) => {
+ // Nobody sees transient effects in a hidden tab; persistent markers show the outcome.
+ if (document.hidden) return;
+ const frame = ctx.getFrame();
+ const add: Fx[] = [];
+ for (const e of events) add.push(...effectsFor(e, frame, ctx));
+ if (add.length) setItems((prev) => [...prev, ...add].slice(-24));
+ }),
+ [ctx],
+ );
+
+ const done = useCallback((id: number) => setItems((prev) => prev.filter((f) => f.id !== id)), []);
+
+ return (
+
+ {items.map((f) =>
+ f.kind === 'packet' ? (
+
+ ) : f.kind === 'burst' ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ );
+}
+
+/** Where an agent's work shows: above its station if it holds one, else above its head. */
+function anchorFor(ctx: SceneCtx, frame: SceneFrame, agentId: string | null | undefined): P3 | null {
+ if (!agentId) return null;
+ const c = frame.state.world.characters[agentId];
+ if (!c) return null;
+ const station = c.stationId ? ctx.world.stationsById[c.stationId] : undefined;
+ if (station) {
+ const [x, , z] = toScene(ctx.world, station.cell[0], station.cell[1]);
+ // High enough to clear the badge of a character standing in front of the station.
+ return [x, cellTop(ctx.world, station.cell[0], station.cell[1]) + 2.6, z];
+ }
+ const pose = poseAt(ctx.world, frame, agentId);
+ if (!pose) return null;
+ const [x, , z] = toScene(ctx.world, pose.x, pose.y);
+ return [x, heightAt(ctx.world, pose.x, pose.y) + 2.9, z];
+}
+
+function effectsFor(e: AnyEvent, frame: SceneFrame, ctx: SceneCtx): Fx[] {
+ if (isUnknownEvent(e)) return [];
+ const exec = frame.state.exec;
+ switch (e.type) {
+ case 'tool.call_completed':
+ case 'tool.call_failed': {
+ const call = exec.toolCalls.get(e.payload.callId);
+ const pos = anchorFor(ctx, frame, call?.agentId);
+ const ok = e.type === 'tool.call_completed' && e.payload.status === 'success';
+ return pos ? [{ id: nextId++, born: performance.now(), kind: 'burst', pos, ok }] : [];
+ }
+ case 'artifact.created': {
+ const by = e.payload.producedByCallId ? exec.toolCalls.get(e.payload.producedByCallId)?.agentId : e.actorId;
+ const pos = anchorFor(ctx, frame, by);
+ if (!pos) return [];
+ return [
+ {
+ id: nextId++,
+ born: performance.now(),
+ kind: 'artifact',
+ pos: [pos[0], pos[1] - 0.35, pos[2]],
+ color: ctx.palette.ice,
+ },
+ ];
+ }
+ case 'agent.message_sent': {
+ const { world } = ctx;
+ const from = poseAt(world, frame, e.payload.fromAgentId);
+ const start: Cell | undefined = from ? from.cell : hubRing(world)[0];
+ if (!start) return [];
+ const color = identityHex(ctx.palette, frame.state.exec, e.payload.fromAgentId);
+ const targets =
+ e.payload.to === 'all'
+ ? frame.state.world.characterOrder.filter((id) => id !== e.payload.fromAgentId)
+ : [e.payload.to];
+ const out: Fx[] = [];
+ for (const id of targets) {
+ const to = poseAt(world, frame, id);
+ if (!to) continue;
+ const cells = bfs(world, start, to.cell) ?? [start, to.cell];
+ out.push({ id: nextId++, born: performance.now(), kind: 'packet', pts: pathPoints(world, cells, 1.05), color });
+ }
+ return out;
+ }
+ default:
+ return [];
+ }
+}
+
+const SPEED = 7; // cells per second
+
+/** A glowing packet that travels the BFS path, leaving a fading trail (static line under reduced motion). */
+function Packet({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) {
+ const { reducedMotion, palette } = useScene();
+ const len = useMemo(() => polylineLength(fx.pts), [fx.pts]);
+ const trail = useMemo(() => ribbonGeometry(fx.pts, 0.06, { uPerUnit: 1 / Math.max(len, 0.001), extend: true }), [fx.pts, len]);
+ useEffect(() => () => trail.dispose(), [trail]);
+ const root = useRef(null);
+ const head = useRef(null);
+ const trailMat = useRef(null);
+ const travel = Math.min(3, Math.max(0.6, len / SPEED));
+ const total = reducedMotion ? 1.6 : travel + 0.6;
+ const v = useMemo(() => new Vector3(), []);
+ const c = useMemo(() => stateGlow(palette, fx.color, 1.6), [fx.color, palette]);
+ const blending = palette.glow ? AdditiveBlending : NormalBlending;
+
+ useFrame(() => {
+ const age = ageOf(fx);
+ if (age > total) {
+ if (root.current) root.current.visible = false;
+ onDone(fx.id);
+ return;
+ }
+ if (reducedMotion) {
+ if (head.current) head.current.visible = false;
+ return;
+ }
+ const s = Math.min(1, age / travel) * len;
+ pointAlong(fx.pts, s, v);
+ if (head.current) {
+ head.current.position.copy(v);
+ head.current.visible = age <= travel;
+ }
+ if (trailMat.current) trailMat.current.opacity = age <= travel ? 0.5 : 0.5 * (1 - (age - travel) / 0.6);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** A restrained outcome pulse: success is one ring with a check, an error a double ring with an X. */
+function Burst({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) {
+ const { reducedMotion, palette } = useScene();
+ const group = useRef(null);
+ const ringA = useRef(null);
+ const ringB = useRef(null);
+ const mats = useRef([]);
+ const color = useMemo(() => stateGlow(palette, fx.ok ? palette.success : palette.failure, 1.6), [fx.ok, palette]);
+ const total = fx.ok ? 1.1 : 1.6;
+
+ useFrame(() => {
+ const age = ageOf(fx);
+ if (age > total) {
+ if (group.current) group.current.visible = false;
+ onDone(fx.id);
+ return;
+ }
+ if (reducedMotion) return;
+ const p = age / total;
+ if (group.current) group.current.position.y = fx.pos[1] + p * 0.35;
+ const pulse = (q: number) => 0.4 + q * 1.4;
+ ringA.current?.scale.setScalar(pulse(Math.min(1, age / 0.7)));
+ if (ringB.current) {
+ const q = Math.max(0, Math.min(1, (age - 0.35) / 0.7));
+ ringB.current.scale.setScalar(pulse(q));
+ ringB.current.visible = !fx.ok && age > 0.35;
+ }
+ for (const m of mats.current) m.opacity = 1 - p * p;
+ });
+
+ const mat = (i: number) => (m: MeshBasicMaterial | null) => {
+ if (m) mats.current[i] = m;
+ };
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Glyph: check or X, as two bars (billboarded loosely by facing the iso camera). */}
+
+ {fx.ok ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ [1, -1].map((r, i) => (
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
+
+/** A small cube pops out above the station when an artifact is recorded, then fades. */
+function ArtifactPop({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) {
+ const { reducedMotion, palette } = useScene();
+ const g = useRef(null);
+ const mat = useRef(null);
+ const color = useMemo(() => stateGlow(palette, fx.color, 1.2), [fx.color, palette]);
+ const total = 2.4;
+
+ useFrame(() => {
+ const age = ageOf(fx);
+ if (age > total) {
+ if (g.current) g.current.visible = false;
+ onDone(fx.id);
+ return;
+ }
+ if (reducedMotion || !g.current) return;
+ const pop = age < 0.35 ? easeOutBack(age / 0.35) : 1;
+ g.current.scale.setScalar(Math.max(0.001, pop));
+ g.current.position.y = fx.pos[1] + Math.min(1, age / total) * 0.5;
+ g.current.rotation.y = age * 2.2;
+ g.current.rotation.x = age * 1.3;
+ if (mat.current) mat.current.opacity = age > total - 0.6 ? (total - age) / 0.6 : 1;
+ });
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function easeOutBack(t: number): number {
+ const c1 = 1.70158;
+ const c3 = c1 + 1;
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
+}
+
+/* ── Incident beacons ──────────────────────────────────────────────────── */
+
+interface Beacon {
+ inc: Incident;
+ stationId: string;
+ index: number;
+}
+
+/** Persistent markers for exec.incidents at the station where they happened (muted once resolved). */
+function IncidentBeacons({ selectedSeq }: { selectedSeq: number | null }) {
+ const ctx = useScene();
+ const [beacons, setBeacons] = useState([]);
+ const sig = useRef('');
+
+ useFrame(() => {
+ const frame = ctx.getFrame();
+ const incs = frame.state.exec.incidents;
+ let s = '';
+ for (const i of incs) s += `${i.eventId}:${i.resolvedSeq === null ? 0 : 1};`;
+ if (s === sig.current) return;
+ sig.current = s;
+ // Statuses are cached per RunState; computing them also remembers each incident's station.
+ stationStatuses(ctx, frame.state);
+ const perStation = new Map();
+ const next: Beacon[] = [];
+ for (const inc of incs) {
+ if (inc.kind === 'run_failed') continue;
+ const stationId = incidentStationId(ctx.world, frame.state, inc, ctx.incidentStations);
+ if (!stationId) continue;
+ const index = perStation.get(stationId) ?? 0;
+ perStation.set(stationId, index + 1);
+ next.push({ inc, stationId, index });
+ }
+ setBeacons(next);
+ });
+
+ return (
+
+ {beacons.map((b) => (
+
+ ))}
+
+ );
+}
+
+export const INCIDENT_LABEL: Record = {
+ tool_error: 'Tool error',
+ tool_failed: 'Tool failed',
+ task_failed: 'Task failed',
+ run_failed: 'Run failed',
+};
+
+/**
+ * An incident marker: a thin line up from the station to a small gem with a
+ * chip naming the failure. Clicking either opens the failing event (the path
+ * from the world to the investigation). Resolved incidents shrink and mute.
+ */
+function BeaconMarker({ beacon, selected }: { beacon: Beacon; selected: boolean }) {
+ const { world, reducedMotion, palette, selectEvent } = useScene();
+ const station = world.stationsById[beacon.stationId]!;
+ const zone = world.zonesById[station.zoneId]?.label ?? null;
+ const inc = beacon.inc;
+ const open = inc.resolvedSeq === null;
+ const [x, , z] = toScene(world, station.cell[0], station.cell[1]);
+ const base = cellTop(world, station.cell[0], station.cell[1]);
+ const y = base + 2.7 + beacon.index * 0.55;
+ const color = useMemo(() => (open ? stateGlow(palette, palette.failure, 1.5) : new Color(palette.muted)), [open, palette]);
+ const gem = useRef(null);
+ const anchor = useMemo(() => new Vector3(x, y + 0.3, z), [x, y, z]);
+ useLabel(
+ `incident:${inc.eventId}`,
+ {
+ kind: 'incident',
+ open,
+ seq: inc.seq,
+ selected,
+ title: inc.resolution === 'later_success' ? `${inc.message} (a later call of the same kind succeeded; inferred)` : inc.message,
+ short: open ? INCIDENT_LABEL[inc.kind] : 'Resolved',
+ // The full label names the environment: its sign may give way to this label and the station's.
+ // Traced runs: "resolved" is Runloop's inference from a later success, and says so.
+ text: open
+ ? `${zone ? `${zone} · ` : ''}${INCIDENT_LABEL[inc.kind]} · ${clip(inc.message, 32)}`
+ : `${inc.resolution === 'later_success' ? 'Resolved (inferred)' : 'Resolved'} · ${INCIDENT_LABEL[inc.kind]}`,
+ },
+ anchor,
+ );
+
+ useFrame(({ clock }) => {
+ if (!gem.current || reducedMotion || !open) return;
+ gem.current.rotation.y = clock.elapsedTime * 0.9;
+ });
+
+ const onClick = (e: { stopPropagation(): void }) => {
+ e.stopPropagation();
+ selectEvent(inc.seq, { seek: false });
+ };
+
+ return (
+
+ {open && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {selected && (
+
+
+
+
+ )}
+
+
+ );
+}
+
+const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
diff --git a/apps/studio/src/scene/Icon.tsx b/apps/studio/src/scene/Icon.tsx
new file mode 100644
index 0000000..9f6d099
--- /dev/null
+++ b/apps/studio/src/scene/Icon.tsx
@@ -0,0 +1,32 @@
+import type { IconName } from './badges';
+import styles from './scene.module.css';
+
+/** Tiny inline stroke icons (no icon font or emoji, so they render everywhere). */
+const PATHS: Record = {
+ search: 'M10.5 4a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM15.5 15.5L20 20',
+ hammer: 'M13 6l5 5M4 20l9-9M11 4l3-1l7 7l-1 3l-3 0l-6-6z',
+ flask: 'M9 3h6M10 3v6l-5.2 9.2A1.5 1.5 0 0 0 6.1 21h11.8a1.5 1.5 0 0 0 1.3-2.8L14 9V3M7.5 15h9',
+ rocket: 'M12 2.5c3 2.5 4.2 6.5 3.2 11.5H8.8C7.8 9 9 5 12 2.5zM8.8 14L6 17.5l3.2.5M15.2 14l2.8 3.5l-3.2.5M10.5 18.5L12 21.5l1.5-3M12 8v1.5',
+ bot: 'M6 8h12v10H6zM12 4v4M9.5 13h0M14.5 13h0M4 12v3M20 12v3',
+ dot: 'M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6z',
+ walk: 'M5 12h12M13 7l5 5l-5 5',
+ gear: 'M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6zM12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1',
+ hourglass: 'M7 3h10M7 21h10M8 3c0 5 8 5 8 9s-8 4-8 9M16 3c0 5-8 5-8 9s8 4 8 9',
+ question: 'M9 9a3 3 0 1 1 4.5 2.6c-1 .6-1.5 1.2-1.5 2.4M12 18v.5',
+ check: 'M5 12.5l4.5 4.5L19 7',
+ cross: 'M6 6l12 12M18 6L6 18',
+ pause: 'M9 5v14M15 5v14',
+ alert: 'M12 3L2.5 20h19zM12 9.5v5M12 17.5v.5',
+ frame: 'M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5',
+ target: 'M12 4a8 8 0 1 0 0 16a8 8 0 1 0 0-16zM12 9.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5zM12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3',
+ eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12zM12 9.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5z',
+ terminal: 'M4 5h16v14H4zM7.5 9.5l3 2.5-3 2.5M12.5 15h4',
+};
+
+export function Icon({ name, color, className }: { name: IconName; color?: string; className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/studio/src/scene/Paths.tsx b/apps/studio/src/scene/Paths.tsx
new file mode 100644
index 0000000..adbf94b
--- /dev/null
+++ b/apps/studio/src/scene/Paths.tsx
@@ -0,0 +1,40 @@
+'use client';
+import { memo, useEffect, useMemo } from 'react';
+import { DoubleSide } from 'three';
+import { useScene } from './context';
+import { glowPaths, pathPoints } from './layout';
+import { ribbonGeometry } from './textures';
+
+/**
+ * Walkways from the Atrium to each room door, inlaid in the floor. Static
+ * Layer-3 geometry derived from world-engine BFS: no glow and no motion,
+ * because nothing is happening on them (rule zero).
+ */
+export const Paths = memo(function Paths() {
+ const { world, palette } = useScene();
+
+ const ribbons = useMemo(
+ () =>
+ glowPaths(world)
+ .filter((p) => !p.branch)
+ .map((p, i) => ({ key: p.key, geo: ribbonGeometry(pathPoints(world, p.cells, 0.004 + (i % 4) * 0.0015), 0.16, { extend: true }) })),
+ [world],
+ );
+
+ useEffect(
+ () => () => {
+ for (const r of ribbons) r.geo.dispose();
+ },
+ [ribbons],
+ );
+
+ return (
+
+ {ribbons.map((r) => (
+
+
+
+ ))}
+
+ );
+});
diff --git a/apps/studio/src/scene/Props.tsx b/apps/studio/src/scene/Props.tsx
new file mode 100644
index 0000000..0311f0b
--- /dev/null
+++ b/apps/studio/src/scene/Props.tsx
@@ -0,0 +1,167 @@
+'use client';
+import type { Prop } from '@runloop-world/world-engine';
+import { memo, useMemo } from 'react';
+import { useScene } from './context';
+import { cellTop, isWall, toScene, yawFor } from './layout';
+import { desaturate, mixHex, type ScenePalette } from './palette';
+import { blobShadowTexture } from './textures';
+
+/** Decorative props from the world definition: simple primitives in the architecture's tones, never glowing. */
+export const Props = memo(function Props() {
+ const { world, palette } = useScene();
+ const tones = useMemo(() => propTones(palette), [palette]);
+ return (
+
+ {world.def.props.map((p, i) => {
+ const [x, , z] = toScene(world, p.cell[0], p.cell[1]);
+ const [fx, fy] = propFront(world, p);
+ return (
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+});
+
+interface Tones {
+ dark: string;
+ body: string;
+ light: string;
+ face: string;
+ detail: string;
+ leaf: string;
+ leafDark: string;
+ wood: string;
+ woodDark: string;
+}
+
+function propTones(p: ScenePalette): Tones {
+ return {
+ dark: p.metalDark,
+ body: p.metal,
+ light: p.metalLight,
+ face: p.screenOff,
+ detail: mixHex(p.metal, p.muted, 0.5),
+ leaf: desaturate(mixHex(p.metal, p.success, 0.5), 0.55),
+ leafDark: desaturate(mixHex(p.metalDark, p.success, 0.4), 0.55),
+ wood: desaturate(mixHex(p.metal, p.warning, 0.28), 0.5),
+ woodDark: desaturate(mixHex(p.metalDark, p.warning, 0.22), 0.5),
+ };
+}
+
+/** Face away from adjacent walls, towards open floor (S, E, N, W preference). */
+function propFront(world: ReturnType['world'], p: Prop): [number, number] {
+ const [px, py] = p.cell;
+ for (const [dx, dy] of [
+ [0, 1],
+ [1, 0],
+ [0, -1],
+ [-1, 0],
+ ] as const) {
+ if (isWall(world, px - dx, py - dy) && !isWall(world, px + dx, py + dy)) return [dx, dy];
+ }
+ return [0, 1];
+}
+
+function PropModel({ kind, t }: { kind: Prop['kind']; t: Tones }) {
+ switch (kind) {
+ case 'rack':
+ return (
+
+
+
+
+
+
+
+
+
+ {[0.3, 0.52, 0.74, 0.96].map((y) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ case 'console':
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ case 'crate':
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ case 'plant':
+ return (
+
+
+
+
+
+ {[
+ [0, 0.5, 0, 0.2],
+ [0.1, 0.42, 0.06, 0.15],
+ [-0.09, 0.45, -0.05, 0.14],
+ [0.02, 0.66, -0.02, 0.12],
+ ].map(([px, py, pz, r], i) => (
+
+
+
+
+ ))}
+
+ );
+ case 'lamp':
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/apps/studio/src/scene/SceneOverlay.tsx b/apps/studio/src/scene/SceneOverlay.tsx
new file mode 100644
index 0000000..66f1ef5
--- /dev/null
+++ b/apps/studio/src/scene/SceneOverlay.tsx
@@ -0,0 +1,174 @@
+'use client';
+import type { ExecState, Incident } from '@runloop-world/core';
+import type { RunState } from '@runloop-world/replay';
+import type { CompiledWorld } from '@runloop-world/world-engine';
+import { useEffect, useState, useSyncExternalStore } from 'react';
+import { runProvenance } from '../state/provenance';
+import type { CameraBus } from './context';
+import { Icon } from './Icon';
+import { incidentStationId } from './layout';
+import type { SceneFrame, SceneStatus } from './types';
+import styles from './scene.module.css';
+
+/** What the overlay shows; recomputed when the frame's state changes, compared by value. */
+interface OverlayInfo {
+ provenance: string;
+ /** Incidents placed in the world at the playhead, open ones first. */
+ incidents: { seq: number; stationId: string; open: boolean }[];
+ /** Subagents traced without a robot (over the cap of embodied subagents). */
+ unembodied: number;
+}
+
+/** Provenance chip text (design-system.md, Provenance labelling). */
+export function provenanceLabel(exec: ExecState): string {
+ const p = runProvenance(null, exec);
+ if (p.kind !== 'executed') return p.label;
+ const adapter = exec.run.adapter;
+ if (!adapter) return 'Executed';
+ return `Executed · ${adapter.kind === 'scripted' ? 'Scripted adapter' : adapter.label}`;
+}
+
+/** Subagents (owners of delegated tasks) that have no robot in the world. */
+export function unembodiedSubagents(exec: ExecState): number {
+ const owners = new Set();
+ for (const id of exec.taskOrder) {
+ const t = exec.tasks[id];
+ if (t?.kind === 'delegated' && t.ownerId) owners.add(t.ownerId);
+ }
+ let n = 0;
+ for (const id of owners) if (exec.agents[id] && !exec.agents[id]!.embodied) n += 1;
+ return n;
+}
+
+function infoOf(world: CompiledWorld, state: RunState, cache: Map): OverlayInfo {
+ const exec = state.exec;
+ const incidents: OverlayInfo['incidents'] = [];
+ for (const inc of exec.incidents as Incident[]) {
+ if (inc.kind === 'run_failed') continue;
+ const stationId = incidentStationId(world, state, inc, cache);
+ if (stationId) incidents.push({ seq: inc.seq, stationId, open: inc.resolvedSeq === null });
+ }
+ incidents.sort((a, b) => Number(b.open) - Number(a.open) || a.seq - b.seq);
+ return {
+ provenance: provenanceLabel(exec),
+ incidents,
+ unembodied: unembodiedSubagents(exec),
+ };
+}
+
+/**
+ * Floating controls over the world (glass, per the design system): the camera
+ * rail (overview, focus the selection, next error) and the provenance chip
+ * that says where the events come from and whether they are live.
+ */
+export function SceneOverlay({
+ world,
+ getFrame,
+ camera,
+ selectedAgentId,
+ selectedStationId,
+ selectedSeq,
+ onSelectEvent,
+ status,
+ incidentStations,
+}: {
+ world: CompiledWorld;
+ getFrame: () => SceneFrame;
+ camera: CameraBus;
+ selectedAgentId: string | null;
+ selectedStationId: string | null;
+ selectedSeq: number | null;
+ onSelectEvent: (seq: number, opts: { seek: boolean }) => void;
+ status: SceneStatus;
+ incidentStations: Map;
+}) {
+ const [info, setInfo] = useState(() => infoOf(world, getFrame().state, incidentStations));
+ const overview = useSyncExternalStore(camera.subscribe, camera.getOverview, camera.getOverview);
+
+ // Poll the frame on animation frames, but re-render only when what is shown changes.
+ useEffect(() => {
+ let raf = 0;
+ let lastState: RunState | null = null;
+ let lastKey = '';
+ const loop = () => {
+ const state = getFrame().state;
+ if (state !== lastState) {
+ lastState = state;
+ const next = infoOf(world, state, incidentStations);
+ const key = JSON.stringify(next);
+ if (key !== lastKey) {
+ lastKey = key;
+ setInfo(next);
+ }
+ }
+ raf = requestAnimationFrame(loop);
+ };
+ raf = requestAnimationFrame(loop);
+ return () => cancelAnimationFrame(raf);
+ }, [world, getFrame, incidentStations]);
+
+ const canFocus = selectedAgentId !== null || selectedStationId !== null;
+ const focus = () => {
+ if (selectedAgentId) camera.request({ kind: 'focusAgent', agentId: selectedAgentId });
+ else if (selectedStationId) camera.request({ kind: 'focusStation', stationId: selectedStationId });
+ };
+ const nextError = () => {
+ const list = info.incidents;
+ if (list.length === 0) return;
+ const i = list.findIndex((x) => x.seq === selectedSeq);
+ const next = list[(i + 1) % list.length]!;
+ onSelectEvent(next.seq, { seek: false });
+ camera.request({ kind: 'focusStation', stationId: next.stationId });
+ };
+ const errors = info.incidents.length;
+ const open = info.incidents.filter((x) => x.open).length;
+
+ return (
+ <>
+
+ camera.request({ kind: 'overview' })} title="Overview: frame the whole world" aria-label="Overview: frame the whole world">
+
+
+
+
+
+
+
+ {open > 0 && (
+
+ {open}
+
+ )}
+
+
+
+ {info.unembodied > 0 && (
+
+ +{info.unembodied} {info.unembodied === 1 ? 'subagent' : 'subagents'} without a robot
+
+ )}
+
+
+ {info.provenance}
+
+
+ {status.text}
+
+
+ >
+ );
+}
diff --git a/apps/studio/src/scene/Stations.tsx b/apps/studio/src/scene/Stations.tsx
new file mode 100644
index 0000000..d3d17cc
--- /dev/null
+++ b/apps/studio/src/scene/Stations.tsx
@@ -0,0 +1,444 @@
+'use client';
+import { useFrame, type ThreeEvent } from '@react-three/fiber';
+import type { Station } from '@runloop-world/world-engine';
+import { memo, useMemo, useRef, useState, type ReactNode } from 'react';
+import { Color, DoubleSide, FrontSide, Vector3, type Mesh, type MeshBasicMaterial, type Object3D } from 'three';
+import { stationStatuses, useScene, type StationStatus } from './context';
+import { useLabel } from './labels';
+import { cellTop, stationColor, stationFront, toScene, yawFor } from './layout';
+import { desaturate, mixHex, stateGlow, zoneAccent, type ScenePalette } from './palette';
+import { blobShadowTexture } from './textures';
+
+/** Animated parts a station model registers so the live status can drive them. */
+interface Rig {
+ screens: MeshBasicMaterial[];
+ /** Test rig lights: [running, failing, verified]. */
+ gauges: MeshBasicMaterial[];
+ arm: Object3D | null;
+ forearm: Object3D | null;
+ rocket: Object3D | null;
+}
+
+/** What a station's screens show, from Layer-1 state only. */
+type ScreenState = 'idle' | 'occupied' | 'busy' | 'awaiting' | 'incident';
+
+function screenState(st: StationStatus): ScreenState {
+ if (st.openIncidents.length > 0) return 'incident';
+ if (st.busy) return 'busy';
+ if (st.awaiting) return 'awaiting';
+ return st.occupant ? 'occupied' : 'idle';
+}
+
+/** Screen colours: lime only while a call runs here; failure and warning for outcomes; unlit otherwise. */
+function screenColors(p: ScenePalette, zone: string): Record {
+ return {
+ idle: new Color(mixHex(p.screenOff, desaturate(zone, 0.4), 0.14)),
+ occupied: new Color(p.theme === 'light' ? mixHex(p.screenOff, p.tokens.graphite, 0.45) : mixHex(p.screenOff, p.ice, 0.32)),
+ busy: stateGlow(p, p.active, 1.5),
+ awaiting: stateGlow(p, p.warning, 1.4),
+ incident: stateGlow(p, p.failure, 1.5),
+ };
+}
+
+export function Stations({
+ selectedStationId,
+ onSelectStation,
+}: {
+ selectedStationId: string | null;
+ onSelectStation: (id: string | null) => void;
+}) {
+ const { world } = useScene();
+ return (
+
+ {world.def.stations.map((s) => (
+
+ ))}
+
+ );
+}
+
+const StationNode = memo(function StationNode({
+ station,
+ selected,
+ onSelect,
+}: {
+ station: Station;
+ selected: boolean;
+ onSelect: (id: string | null) => void;
+}) {
+ const ctx = useScene();
+ const { world, getFrame, reducedMotion, palette } = ctx;
+ const [hover, setHover] = useState(false);
+ const zone = stationColor(world, station);
+ const accent = zoneAccent(palette, zone);
+ const colors = useMemo(() => screenColors(palette, zone), [palette, zone]);
+ const gaugeOn = useMemo(() => [stateGlow(palette, palette.active, 1.6), stateGlow(palette, palette.failure, 1.6), stateGlow(palette, palette.success, 1.5)], [palette]);
+ const gaugeOff = useMemo(() => new Color(palette.screenOff), [palette]);
+ const rig = useRef({ screens: [], gauges: [], arm: null, forearm: null, rocket: null }).current;
+ const runRing = useRef(null);
+ const runMat = useRef(null);
+ const [fx, fy] = stationFront(station);
+ const top = cellTop(world, station.cell[0], station.cell[1]);
+ const [x, , z] = toScene(world, station.cell[0], station.cell[1]);
+ const labelAnchor = useMemo(() => new Vector3(x, top + 1.9, z), [x, top, z]);
+ useLabel(`station:${station.id}`, selected || hover ? { kind: 'station', text: station.label, color: accent } : null, labelAnchor);
+ const runColor = useMemo(() => stateGlow(palette, palette.active, 1.6), [palette]);
+
+ useFrame(({ clock }) => {
+ const frame = getFrame();
+ const st = stationStatuses(ctx, frame.state).get(station.id);
+ if (!st) return;
+ const t = clock.elapsedTime;
+ const state = screenState(st);
+ const pulse = state === 'busy' && !reducedMotion ? 0.82 + 0.18 * Math.sin(t * 5) : 1;
+ for (const m of rig.screens) m.color.copy(colors[state]).multiplyScalar(pulse);
+
+ if (rig.gauges.length === 3) {
+ const lit = [st.busy, st.openIncidents.length > 0, st.openIncidents.length === 0 && st.resolvedIncidents.length > 0];
+ rig.gauges.forEach((m, i) => m.color.copy(lit[i] ? gaugeOn[i]! : gaugeOff));
+ }
+ // Machines move only while a call runs here.
+ const animate = st.busy && !reducedMotion && !frame.state.world.paused;
+ if (rig.arm) rig.arm.rotation.y = animate ? Math.sin(t * 1.7) * 0.7 : 0.4;
+ if (rig.forearm) rig.forearm.rotation.z = animate ? -0.9 + Math.sin(t * 3.1) * 0.35 : -0.9;
+ if (rig.rocket) {
+ rig.rocket.position.y = 0.12 + (animate ? 0.04 + Math.sin(t * 9) * 0.015 : 0);
+ rig.rocket.rotation.y = animate ? t * 0.8 : 0;
+ }
+ if (runRing.current) runRing.current.visible = st.busy;
+ if (runMat.current && st.busy) runMat.current.color.copy(runColor).multiplyScalar(pulse);
+ });
+
+ const select = (e: ThreeEvent) => {
+ e.stopPropagation();
+ onSelect(station.id);
+ };
+
+ return (
+
+ {
+ e.stopPropagation();
+ setHover(true);
+ document.body.style.cursor = 'pointer';
+ }}
+ onPointerOut={() => {
+ setHover(false);
+ document.body.style.cursor = '';
+ }}
+ >
+
+
+
+
+
+
+ {/* Floor pad outline in the zone's muted tint. */}
+
+
+
+
+ {/* Running station: a lime ring (active execution only). */}
+
+
+
+
+ {selected && (
+
+
+
+
+ )}
+
+ );
+});
+
+/* ── Models (front = +z, origin on the floor at the cell centre) ─────────── */
+
+function Metal({ tone = 'metal', rough = 0.45 }: { tone?: 'metal' | 'metalDark' | 'metalLight'; rough?: number }) {
+ const { palette } = useScene();
+ return ;
+}
+
+/** A screen whose colour carries the station's state (registered with the rig). */
+function Screen({ rig, children, double }: { rig: Rig; children: ReactNode; double?: boolean }) {
+ return (
+
+ {children}
+ {
+ if (m && !rig.screens.includes(m)) rig.screens.push(m);
+ }}
+ toneMapped={false}
+ />
+
+ );
+}
+
+function Glass() {
+ const { palette } = useScene();
+ return ;
+}
+
+function StationModel({ kind, accent, rig }: { kind: Station['kind']; accent: string; rig: Rig }) {
+ switch (kind) {
+ case 'library':
+ return ;
+ case 'build':
+ return ;
+ case 'test':
+ return ;
+ case 'gate':
+ return ;
+ case 'ops':
+ return ;
+ }
+}
+
+/** A thin status strip on a model's back face, so machines facing away from the camera still show their state. */
+function BackStrip({ rig, y, z, w }: { rig: Rig; y: number; z: number; w: number }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Library: a tall terminal with a big screen and a keyboard ledge. */
+function Terminal({ rig }: { rig: Rig }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Build: a workbench with a monitor and a robotic arm. */
+function Workbench({ accent, rig }: { accent: string; rig: Rig }) {
+ return (
+
+
+
+
+
+ {[
+ [-0.38, -0.25],
+ [0.38, -0.25],
+ [-0.38, 0.25],
+ [0.38, 0.25],
+ ].map(([lx, lz], i) => (
+
+
+
+
+ ))}
+
+
+
+
+ {/* Monitor */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Part on the bench */}
+
+
+
+
+ {/* Robotic arm */}
+
+
+
+
+
+ void (rig.arm = o)} position={[0, 0.08, 0]}>
+
+
+
+
+
+ void (rig.forearm = o)} position={[0, 0.34, 0]} rotation={[0, 0, -0.9]}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Test: a rig with a status screen, three status lights and sample tubes. */
+function TestRig({ rig }: { rig: Rig }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[-0.22, 0, 0.22].map((gx, i) => (
+
+
+ {
+ if (m && !rig.gauges.includes(m)) rig.gauges[i] = m;
+ }}
+ toneMapped={false}
+ />
+
+ ))}
+ {[-0.25, 0.25].map((tx) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/** Gate: an arch whose inner frame shows the approval state. */
+function Gate({ rig }: { rig: Rig }) {
+ return (
+
+
+
+
+
+ {[-0.4, 0.4].map((px) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ {[
+ [-0.31, 0.72, 0.03, 1.28],
+ [0.31, 0.72, 0.03, 1.28],
+ [0, 1.375, 0.65, 0.03],
+ ].map(([sx, sy, w, h], i) => (
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+/** Ops: a round launch pad with a small rocket and a console. */
+function LaunchPad({ accent, rig }: { accent: string; rig: Rig }) {
+ const { palette } = useScene();
+ return (
+
+
+
+
+
+
+
+
+
+ void (rig.rocket = o)} position={[0, 0.12, -0.02]}>
+
+
+
+
+
+
+
+
+ {[0, (2 * Math.PI) / 3, (4 * Math.PI) / 3].map((a) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/studio/src/scene/WorldCanvas.tsx b/apps/studio/src/scene/WorldCanvas.tsx
new file mode 100644
index 0000000..2876f78
--- /dev/null
+++ b/apps/studio/src/scene/WorldCanvas.tsx
@@ -0,0 +1,169 @@
+'use client';
+import { Environment, Lightformer } from '@react-three/drei';
+import { Canvas, useFrame } from '@react-three/fiber';
+import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Board } from './Board';
+import { CameraRig } from './CameraRig';
+import { Characters } from './Characters';
+import { CameraBus, SceneContext, type SceneCtx } from './context';
+import { Effects } from './Effects';
+import { LabelLayer, LabelProjector, LabelStore } from './labels';
+import { buildPalette, documentTokens, type ScenePalette } from './palette';
+import { Paths } from './Paths';
+import { Props } from './Props';
+import { SceneOverlay } from './SceneOverlay';
+import { Stations } from './Stations';
+import type { SceneProps } from './types';
+import styles from './scene.module.css';
+
+/** Screen areas the labels keep clear of: the stage's toolbars and roster, the provenance chips, the camera rail. */
+const obstacles = (w: number, h: number) => [
+ { l: 0, t: 0, r: 178, b: 46 },
+ { l: w - 284, t: 0, r: w, b: 104 },
+ { l: 0, t: h - 46, r: w, b: h },
+ { l: w - 54, t: h / 2 - 62, r: w, b: h / 2 + 62 },
+];
+
+/**
+ * The 3D world (Layer 3). Reads Layer 1 + 2 state through `getFrame()` every
+ * frame and never mutates it. Rendered by the shell via next/dynamic (ssr:false).
+ */
+export default function WorldCanvas(props: SceneProps) {
+ const {
+ world,
+ getFrame,
+ subscribeEffects,
+ selectedAgentId,
+ selectedStationId,
+ onSelectAgent,
+ onSelectStation,
+ cameraMode,
+ follow,
+ quality,
+ reducedMotion,
+ onReady,
+ selectedSeq = null,
+ onSelectEvent,
+ status = { text: 'Live', live: true, detail: 'Events are arriving now.' },
+ } = props;
+ const wrap = useRef(null);
+ const palette = useScenePalette();
+
+ const [labels] = useState(() => {
+ const s = new LabelStore();
+ s.obstacles = obstacles;
+ return s;
+ });
+ const [camera] = useState(() => new CameraBus());
+ const incidentStations = useMemo(() => new Map(), [world]);
+ // Stable wrapper, so a new callback from the shell does not re-render the whole scene.
+ const selectEventRef = useRef(onSelectEvent);
+ selectEventRef.current = onSelectEvent;
+ const selectEvent = useCallback((seq: number, opts: { seek: boolean }) => selectEventRef.current?.(seq, opts), []);
+ const ctx = useMemo(
+ () => ({ world, getFrame, subscribeEffects, reducedMotion, incidentStations, labels, palette, camera, selectEvent }),
+ [world, getFrame, subscribeEffects, reducedMotion, incidentStations, labels, palette, camera, selectEvent],
+ );
+ const bloom = quality === 'high' && !reducedMotion && palette.glow;
+
+ return (
+
+
{
+ onSelectAgent(null);
+ onSelectStation(null);
+ }}
+ fallback={WebGL is not available, so the 3D world cannot be shown. The Network view, timeline and logs still show everything that happened.
}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {bloom && (
+
+
+
+
+ )}
+
+
selectEvent(seq, { seek: false })} />
+
+
+ );
+}
+
+/** Scene colours from the design tokens; re-read when the theme changes. */
+function useScenePalette(): ScenePalette {
+ const [palette, setPalette] = useState(() => buildPalette(documentTokens()));
+ useEffect(() => {
+ const update = () => {
+ const next = buildPalette(documentTokens());
+ setPalette((prev) => (JSON.stringify(prev.tokens) === JSON.stringify(next.tokens) ? prev : next));
+ };
+ const mo = new MutationObserver(update);
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
+ const mq = window.matchMedia?.('(prefers-color-scheme: light)');
+ mq?.addEventListener('change', update);
+ update();
+ return () => {
+ mo.disconnect();
+ mq?.removeEventListener('change', update);
+ };
+ }, []);
+ return palette;
+}
+
+/** Memoized: re-rendering would re-render its cube map (and PMREM) every time. */
+const Lights = memo(function Lights({ palette }: { palette: ScenePalette }) {
+ const l = palette.lights;
+ return (
+ <>
+
+
+
+
+ {/* Neutral studio reflections for the glossy shells (no network: lightformers render on the GPU). */}
+
+
+
+
+
+ >
+ );
+});
+
+/** Marks the wrapper data-world-ready="true" and calls onReady once the first frame has rendered. */
+function Ready({ wrap, onReady }: { wrap: React.RefObject; onReady?: () => void }) {
+ const frames = useRef(0);
+ useFrame(() => {
+ frames.current += 1;
+ if (frames.current !== 2) return;
+ wrap.current?.setAttribute('data-world-ready', 'true');
+ onReady?.();
+ });
+ return null;
+}
diff --git a/apps/studio/src/scene/badges.ts b/apps/studio/src/scene/badges.ts
new file mode 100644
index 0000000..8815d4d
--- /dev/null
+++ b/apps/studio/src/scene/badges.ts
@@ -0,0 +1,98 @@
+import type { RunState } from '@runloop-world/replay';
+import type { CompiledWorld, PresentationState } from '@runloop-world/world-engine';
+import { usageText } from '../state/labels';
+
+export type IconName =
+ | 'search'
+ | 'hammer'
+ | 'flask'
+ | 'rocket'
+ | 'bot'
+ | 'dot'
+ | 'walk'
+ | 'gear'
+ | 'hourglass'
+ | 'question'
+ | 'check'
+ | 'cross'
+ | 'pause'
+ | 'alert'
+ | 'frame'
+ | 'target'
+ | 'eye'
+ | 'terminal';
+
+/** Badge tones. Only `active` (a running call) uses the lime accent. */
+export type Tone = 'neutral' | 'active' | 'ok' | 'err' | 'warn' | 'muted';
+
+/** CSS colours from tokens.css, so badges follow the theme. */
+export const TONE_COLOR: Record = {
+ neutral: 'var(--rl-text-muted)',
+ active: 'var(--rl-accent-text)',
+ ok: 'var(--rl-success)',
+ err: 'var(--rl-failure)',
+ warn: 'var(--rl-warning)',
+ muted: 'var(--rl-text-faint)',
+};
+
+/** Every presentation state has an icon AND a text label (never colour alone). */
+export const STATE_BADGE: Record = {
+ idle: { icon: 'dot', label: 'Idle', tone: 'muted' },
+ moving: { icon: 'walk', label: 'Walking', tone: 'neutral' },
+ working: { icon: 'gear', label: 'Working', tone: 'active' },
+ waiting: { icon: 'hourglass', label: 'Waiting', tone: 'warn' },
+ awaiting_approval: { icon: 'question', label: 'Needs approval', tone: 'warn' },
+ succeeded: { icon: 'check', label: 'Done', tone: 'ok' },
+ failed: { icon: 'cross', label: 'Failed', tone: 'err' },
+ paused: { icon: 'pause', label: 'Paused', tone: 'muted' },
+};
+
+const PRESET_ICONS: Record = { search: 'search', hammer: 'hammer', flask: 'flask', rocket: 'rocket' };
+export const presetIcon = (icon: string | undefined): IconName => (icon && PRESET_ICONS[icon]) || 'bot';
+
+const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
+
+/**
+ * The badge's second line: the state label plus a short, factual detail
+ * taken from Layer 1/2 (active tool, walking destination, waiting reason).
+ */
+export function badgeDetail(state: PresentationState, agentId: string, run: RunState, world: CompiledWorld): string {
+ const base = STATE_BADGE[state].label;
+ const agent = run.exec.agents[agentId];
+ const ch = run.world.characters[agentId];
+ switch (state) {
+ case 'working': {
+ const call = agent?.activeCallId ? run.exec.toolCalls.get(agent.activeCallId) : undefined;
+ return call ? `${base} · ${call.tool}` : base;
+ }
+ case 'moving': {
+ const t = ch?.target;
+ if (t && (t.kind === 'slot' || t.kind === 'overflow')) {
+ const label = world.stationsById[t.stationId]?.label;
+ return label ? `${base} → ${clip(label, 18)}` : base;
+ }
+ return t?.kind === 'home' ? `${base} → home` : base;
+ }
+ case 'waiting': {
+ // "waiting for a fix" → "Waiting · for a fix"
+ const reason = agent?.stateReason?.replace(/^waiting\s+/i, '');
+ return reason ? `${base} · ${clip(reason, 22)}` : base;
+ }
+ default:
+ return base;
+ }
+}
+
+/** The one-line badge's detail: the running tool, if any (kept short). */
+export function compactDetail(state: PresentationState, agentId: string, run: RunState): string {
+ if (state !== 'working') return '';
+ const agent = run.exec.agents[agentId];
+ const call = agent?.activeCallId ? run.exec.toolCalls.get(agent.activeCallId) : undefined;
+ return call ? clip(call.tool, 14) : '';
+}
+
+/** Reported usage for the badge, or null when the source did not report any (never shown as zero). */
+export function usageLine(run: RunState, agentId: string): string | null {
+ const u = run.exec.agents[agentId]?.usage;
+ return u ? `${usageText(u)} · reported` : null;
+}
diff --git a/apps/studio/src/scene/context.tsx b/apps/studio/src/scene/context.tsx
new file mode 100644
index 0000000..a185f2c
--- /dev/null
+++ b/apps/studio/src/scene/context.tsx
@@ -0,0 +1,127 @@
+'use client';
+import type { Incident } from '@runloop-world/core';
+import type { AnyEvent } from '@runloop-world/events';
+import type { RunState } from '@runloop-world/replay';
+import { matchActivityRule, presentationAt, type CharacterPose, type CompiledWorld } from '@runloop-world/world-engine';
+import { createContext, useContext } from 'react';
+import type { LabelStore } from './labels';
+import { incidentStationId } from './layout';
+import type { ScenePalette } from './palette';
+import type { SceneFrame } from './types';
+
+/** A camera move requested from outside the canvas (the camera rail, selection). */
+export type CameraRequest =
+ | { kind: 'overview' }
+ | { kind: 'focusAgent'; agentId: string }
+ | { kind: 'focusStation'; stationId: string };
+
+/** Tiny bus between DOM controls and the camera rig inside the canvas. */
+export class CameraBus {
+ private handler: ((r: CameraRequest) => void) | null = null;
+ /** True while the camera shows the whole board (no pan/zoom/focus since). */
+ overview = true;
+ private listeners = new Set<() => void>();
+ listen(fn: (r: CameraRequest) => void) {
+ this.handler = fn;
+ return () => {
+ if (this.handler === fn) this.handler = null;
+ };
+ }
+ request(r: CameraRequest) {
+ this.handler?.(r);
+ }
+ setOverview(on: boolean) {
+ if (this.overview === on) return;
+ this.overview = on;
+ for (const fn of this.listeners) fn();
+ }
+ subscribe = (fn: () => void) => {
+ this.listeners.add(fn);
+ return () => void this.listeners.delete(fn);
+ };
+ getOverview = () => this.overview;
+}
+
+/** Shared, rarely-changing inputs for scene components (per-frame data comes from getFrame()). */
+export interface SceneCtx {
+ world: CompiledWorld;
+ getFrame: () => SceneFrame;
+ subscribeEffects: (fn: (events: readonly AnyEvent[]) => void) => () => void;
+ reducedMotion: boolean;
+ /** Remembers which station each incident happened at (see incidentStationId). */
+ incidentStations: Map;
+ /** DOM labels drawn over the canvas. */
+ labels: LabelStore;
+ /** Colours from the design tokens (follows the theme). */
+ palette: ScenePalette;
+ camera: CameraBus;
+ /** Select an event in the shell (stable; a no-op when the shell does not support it). */
+ selectEvent: (seq: number, opts: { seek: boolean }) => void;
+}
+
+export const SceneContext = createContext(null);
+
+export function useScene(): SceneCtx {
+ const ctx = useContext(SceneContext);
+ if (!ctx) throw new Error('useScene() outside ');
+ return ctx;
+}
+
+/** Layer-2 pose of a character at the frame's tick (null when it has no character). */
+export function poseAt(world: CompiledWorld, frame: SceneFrame, agentId: string): CharacterPose | null {
+ const c = frame.state.world.characters[agentId];
+ if (!c) return null;
+ return presentationAt(c, frame.tick, world, { paused: frame.state.world.paused });
+}
+
+export interface StationStatus {
+ /** Agent standing in a slot of this station (first occupied slot). */
+ occupant: string | null;
+ /** Occupant has a tool call in flight (Layer 1): the station is executing. */
+ busy: boolean;
+ /** Occupant is waiting for a permission (the gate). */
+ awaiting: boolean;
+ /** Open (unresolved) incidents placed at this station. */
+ openIncidents: Incident[];
+ resolvedIncidents: Incident[];
+}
+
+const statusCache = new WeakMap>();
+
+/**
+ * Per-station status derived from one RunState. Cached per state object
+ * (ReplayIndex returns the same object for the same sequence), so it is
+ * computed once per playhead step, not once per frame.
+ */
+export function stationStatuses(ctx: Pick, state: RunState): Map {
+ const hit = statusCache.get(state);
+ if (hit) return hit;
+ const out = new Map();
+ for (const s of ctx.world.def.stations) {
+ const ws = state.world.stations[s.id];
+ const occupant = ws?.slots.find((a): a is string => a !== null) ?? null;
+ const agent = occupant ? state.exec.agents[occupant] : undefined;
+ const call = agent?.activeCallId ? state.exec.toolCalls.get(agent.activeCallId) : undefined;
+ const perm = agent?.pendingPermissionId ? state.exec.permissions[agent.pendingPermissionId] : undefined;
+ // A running call lights the station it belongs to (a delegate call, say, does not light the library).
+ const callKind = call?.category
+ ? matchActivityRule(ctx.world.def.activityRules, { taskKind: null, toolCategory: call.category, permission: false })
+ : s.kind;
+ out.set(s.id, {
+ occupant,
+ busy: call?.status === 'running' && callKind === s.kind,
+ awaiting: perm?.status === 'pending',
+ openIncidents: [],
+ resolvedIncidents: [],
+ });
+ }
+ for (const inc of state.exec.incidents) {
+ if (inc.kind === 'run_failed') continue;
+ const id = incidentStationId(ctx.world, state, inc, ctx.incidentStations);
+ const st = id ? out.get(id) : undefined;
+ if (!st) continue;
+ (inc.resolvedSeq === null ? st.openIncidents : st.resolvedIncidents).push(inc);
+ }
+ statusCache.set(state, out);
+ return out;
+}
diff --git a/apps/studio/src/scene/framing.test.ts b/apps/studio/src/scene/framing.test.ts
new file mode 100644
index 0000000..4acf157
--- /dev/null
+++ b/apps/studio/src/scene/framing.test.ts
@@ -0,0 +1,77 @@
+import { readFileSync } from 'node:fs';
+import { loadWorld } from '@runloop-world/world-engine';
+import { Vector3 } from 'three';
+import { describe, expect, it } from 'vitest';
+import { ISO_DIR, TOP_DIR, boardPoints, cubicBezier, easeOut, frameBoard, parseDurationMs, projectToScreen, type Insets } from './framing';
+
+const world = loadWorld(JSON.parse(readFileSync(new URL('../../../../worlds/research-campus/world.json', import.meta.url), 'utf8')));
+const INSETS: Insets = { top: 46, right: 58, bottom: 50, left: 24 };
+
+/** Screen bounding box of the board for a framing. */
+function screenBox(dir: Vector3, w: number, h: number) {
+ const f = frameBoard(world, dir, w, h, INSETS);
+ const pts = boardPoints(world).map((p) => projectToScreen(p, dir, f.target, f.zoom, w, h));
+ return {
+ f,
+ l: Math.min(...pts.map((p) => p.x)),
+ r: Math.max(...pts.map((p) => p.x)),
+ t: Math.min(...pts.map((p) => p.y)),
+ b: Math.max(...pts.map((p) => p.y)),
+ };
+}
+
+describe('fit-to-viewport framing', () => {
+ // Canvas sizes of the Live World stage between 1280 and 1920 px windows, plus tall and tiny panels.
+ const sizes: [number, number][] = [
+ [640, 330],
+ [787, 410],
+ [1267, 518],
+ [1500, 700],
+ [500, 700],
+ [300, 200],
+ ];
+
+ for (const dir of [ISO_DIR, TOP_DIR]) {
+ for (const [w, h] of sizes) {
+ it(`keeps the whole board inside the free area (${dir === ISO_DIR ? 'iso' : 'top'}, ${w}×${h})`, () => {
+ const { l, r, t, b } = screenBox(dir, w, h);
+ const eps = 0.5;
+ expect(l).toBeGreaterThanOrEqual(INSETS.left - eps);
+ expect(r).toBeLessThanOrEqual(w - INSETS.right + eps);
+ expect(t).toBeGreaterThanOrEqual(INSETS.top - eps);
+ expect(b).toBeLessThanOrEqual(h - INSETS.bottom + eps);
+ // ...and uses it: the binding axis touches both insets.
+ const fillsW = Math.abs(l - INSETS.left) < 1 && Math.abs(r - (w - INSETS.right)) < 1;
+ const fillsH = Math.abs(t - INSETS.top) < 1 && Math.abs(b - (h - INSETS.bottom)) < 1;
+ expect(fillsW || fillsH).toBe(true);
+ });
+ }
+ }
+
+ it('zooms with the canvas (bigger stage, bigger board)', () => {
+ expect(frameBoard(world, ISO_DIR, 1500, 700, INSETS).zoom).toBeGreaterThan(frameBoard(world, ISO_DIR, 787, 410, INSETS).zoom);
+ });
+});
+
+describe('camera motion', () => {
+ it('eases out like --rl-ease-out and stays within [0, 1]', () => {
+ expect(easeOut(0)).toBe(0);
+ expect(easeOut(1)).toBe(1);
+ expect(easeOut(0.5)).toBeGreaterThan(0.8); // most of the move happens early
+ let prev = 0;
+ for (let t = 0.05; t <= 1; t += 0.05) {
+ const v = easeOut(t);
+ expect(v).toBeGreaterThanOrEqual(prev - 1e-9);
+ prev = v;
+ }
+ expect(cubicBezier(0, 0, 1, 1)(0.3)).toBeCloseTo(0.3, 3); // linear
+ });
+
+ it('reads duration tokens, including the zeroed reduced-motion value', () => {
+ expect(parseDurationMs('550ms', 1)).toBe(550);
+ expect(parseDurationMs(' 0.4s ', 1)).toBe(400);
+ expect(parseDurationMs('0ms', 550)).toBe(0);
+ expect(parseDurationMs('', 550)).toBe(550);
+ expect(parseDurationMs('fast', 550)).toBe(550);
+ });
+});
diff --git a/apps/studio/src/scene/framing.ts b/apps/studio/src/scene/framing.ts
new file mode 100644
index 0000000..21b2c1d
--- /dev/null
+++ b/apps/studio/src/scene/framing.ts
@@ -0,0 +1,139 @@
+import type { CompiledWorld } from '@runloop-world/world-engine';
+import { Vector3 } from 'three';
+
+/**
+ * Pure camera maths for the orthographic world camera: view directions,
+ * fit-to-viewport framing with insets, and the motion-token easing.
+ */
+
+/** View directions (from target towards the camera). Iso looks from the south-east, so north/west walls are at the back. */
+export const ISO_DIR = new Vector3(1, 1.2, 1).normalize();
+export const TOP_DIR = new Vector3(0, 1, 0.002).normalize();
+export const UP = new Vector3(0, 1, 0);
+
+/** Pixels kept clear around the board: floating controls sit on top and bottom, labels rise above. */
+export interface Insets {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+}
+
+/** Room for the stage's glass controls (top), the roster (bottom) and the camera rail (right). */
+export const DEFAULT_INSETS: Insets = { top: 46, right: 58, bottom: 50, left: 24 };
+
+/** Camera basis for a view direction: screen right and screen up in world space. */
+export function viewBasis(dir: Vector3): { right: Vector3; up: Vector3 } {
+ const fwd = dir.clone().negate();
+ const right = new Vector3().crossVectors(fwd, UP).normalize();
+ const up = new Vector3().crossVectors(right, fwd).normalize();
+ return { right, up };
+}
+
+/** The 3D points that must stay in view: the base slab corners, from under the slab to the top of walls and robots. */
+export function boardPoints(world: CompiledWorld): Vector3[] {
+ const hx = world.width / 2 + 0.8;
+ const hz = world.height / 2 + 0.8;
+ const pts: Vector3[] = [];
+ for (const x of [-hx, hx]) for (const z of [-hz, hz]) for (const y of [-0.7, 2.3]) pts.push(new Vector3(x, y, z));
+ return pts;
+}
+
+export interface Framing {
+ zoom: number;
+ target: Vector3;
+}
+
+/**
+ * Zoom and target that show every point inside the canvas minus `insets`,
+ * centred in that free area, for an orthographic camera looking along -dir.
+ * Works at any aspect ratio; the binding axis decides the zoom.
+ */
+export function frameBounds(points: readonly Vector3[], dir: Vector3, width: number, height: number, insets: Insets = DEFAULT_INSETS): Framing {
+ const { right, up } = viewBasis(dir);
+ let r0 = Infinity;
+ let r1 = -Infinity;
+ let u0 = Infinity;
+ let u1 = -Infinity;
+ for (const p of points) {
+ const r = p.dot(right);
+ const u = p.dot(up);
+ r0 = Math.min(r0, r);
+ r1 = Math.max(r1, r);
+ u0 = Math.min(u0, u);
+ u1 = Math.max(u1, u);
+ }
+ // Never let insets eat the whole canvas (tiny panels still show something).
+ const availW = Math.max(width * 0.4, width - insets.left - insets.right);
+ const availH = Math.max(height * 0.4, height - insets.top - insets.bottom);
+ const zoom = Math.max(1, Math.min(availW / Math.max(1e-6, r1 - r0), availH / Math.max(1e-6, u1 - u0)));
+ // Shift so the bounds' centre lands in the middle of the free area (screen y points down).
+ const offX = (insets.left - insets.right) / 2;
+ const offY = (insets.top - insets.bottom) / 2;
+ const tr = (r0 + r1) / 2 - offX / zoom;
+ const tu = (u0 + u1) / 2 + offY / zoom;
+ return { zoom, target: right.multiplyScalar(tr).add(up.multiplyScalar(tu)) };
+}
+
+/** Fit the whole board. */
+export function frameBoard(world: CompiledWorld, dir: Vector3, width: number, height: number, insets: Insets = DEFAULT_INSETS): Framing {
+ return frameBounds(boardPoints(world), dir, width, height, insets);
+}
+
+/** Screen position (px from the canvas top-left) of a world point for a camera at `target`/`zoom`. */
+export function projectToScreen(p: Vector3, dir: Vector3, target: Vector3, zoom: number, width: number, height: number): { x: number; y: number } {
+ const { right, up } = viewBasis(dir);
+ const d = p.clone().sub(target);
+ return { x: width / 2 + d.dot(right) * zoom, y: height / 2 - d.dot(up) * zoom };
+}
+
+/**
+ * CSS cubic-bezier(x1, y1, x2, y2) as a function of progress t ∈ [0, 1]
+ * (the motion tokens' easing curves). Newton iterations with a bisection fallback.
+ */
+export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
+ const cx = 3 * x1;
+ const bx = 3 * (x2 - x1) - cx;
+ const ax = 1 - cx - bx;
+ const cy = 3 * y1;
+ const by = 3 * (y2 - y1) - cy;
+ const ay = 1 - cy - by;
+ const sx = (s: number) => ((ax * s + bx) * s + cx) * s;
+ const sy = (s: number) => ((ay * s + by) * s + cy) * s;
+ const dx = (s: number) => (3 * ax * s + 2 * bx) * s + cx;
+ return (t: number) => {
+ if (t <= 0) return 0;
+ if (t >= 1) return 1;
+ let s = t;
+ for (let i = 0; i < 8; i++) {
+ const err = sx(s) - t;
+ if (Math.abs(err) < 1e-5) return sy(s);
+ const d = dx(s);
+ if (Math.abs(d) < 1e-6) break;
+ s -= err / d;
+ }
+ let lo = 0;
+ let hi = 1;
+ s = t;
+ for (let i = 0; i < 30; i++) {
+ const v = sx(s);
+ if (Math.abs(v - t) < 1e-5) break;
+ if (v < t) lo = s;
+ else hi = s;
+ s = (lo + hi) / 2;
+ }
+ return sy(s);
+ };
+}
+
+/** --rl-ease-out (cubic-bezier(0.2, 0.8, 0.2, 1)). */
+export const easeOut = cubicBezier(0.2, 0.8, 0.2, 1);
+
+/** Parse a CSS duration token ("550ms", "0.4s", "0ms"); falls back when missing or invalid. */
+export function parseDurationMs(value: string | null | undefined, fallback: number): number {
+ const v = (value ?? '').trim();
+ const m = /^(-?\d*\.?\d+)(ms|s)$/.exec(v);
+ if (!m) return fallback;
+ const n = Number(m[1]) * (m[2] === 's' ? 1000 : 1);
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
+}
diff --git a/apps/studio/src/scene/labelLayout.test.ts b/apps/studio/src/scene/labelLayout.test.ts
new file mode 100644
index 0000000..6bcab3a
--- /dev/null
+++ b/apps/studio/src/scene/labelLayout.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest';
+import { SIGN_SLOTS, noOverlaps, placeLabels, type FormSize, type LabelItem } from './labelLayout';
+
+const badge = (id: string, x: number, y: number, priority = 400, extra: Partial = {}): LabelItem => ({
+ id,
+ x,
+ y,
+ priority,
+ movable: true,
+ forms: [
+ { form: 'compact', w: 110, h: 22 },
+ { form: 'icon', w: 34, h: 22 },
+ ],
+ ...extra,
+});
+
+/** Deterministic pseudo-random numbers. */
+function rng(seed: number) {
+ let s = seed;
+ return () => {
+ s = (s * 1103515245 + 12345) & 0x7fffffff;
+ return s / 0x7fffffff;
+ };
+}
+
+describe('label placement', () => {
+ it('leaves a lone label on its anchor in its best form', () => {
+ const p = placeLabels([badge('a', 300, 200)], { width: 800, height: 400 }).get('a')!;
+ expect(p).toMatchObject({ form: 'compact', x: 300, y: 200, displaced: false });
+ });
+
+ it('never overlaps labels, even for a crowd of characters at the hub', () => {
+ // Nine robots within a few cells of each other (a traced run with 8 subagents at home).
+ const items = Array.from({ length: 9 }, (_, i) => badge(`a${i}`, 380 + (i % 3) * 28, 200 + Math.floor(i / 3) * 14, 400 + i));
+ const placed = placeLabels(items, { width: 800, height: 400 });
+ expect(noOverlaps(items, placed)).toBe(true);
+ expect([...placed.values()].filter((p) => p.form !== null).length).toBeGreaterThanOrEqual(6);
+ });
+
+ it('never overlaps across random scenes and canvas sizes', () => {
+ const r = rng(7);
+ for (let n = 0; n < 200; n++) {
+ const w = 500 + r() * 1400;
+ const h = 300 + r() * 500;
+ const items: LabelItem[] = Array.from({ length: 2 + Math.floor(r() * 12) }, (_, i) => badge(`b${i}`, r() * w, 30 + r() * h, Math.floor(r() * 700)));
+ items.push({ id: 'zone', x: r() * w, y: r() * h, priority: 100, movable: false, slots: SIGN_SLOTS, forms: [{ form: 'full', w: 120, h: 20 }] });
+ const placed = placeLabels(items, { width: w, height: h, gap: 4, margin: 4 });
+ expect(noOverlaps(items, placed)).toBe(true);
+ for (const [, p] of placed) {
+ if (!p.form) continue;
+ expect(p.y).toBeLessThanOrEqual(h - 4 + 1e-6);
+ }
+ }
+ });
+
+ it('places higher priority first: the selected badge keeps the spot in full, others move or collapse', () => {
+ const full: FormSize[] = [{ form: 'full', w: 160, h: 44 }];
+ const items = [badge('other', 300, 200, 400), badge('sel', 305, 202, 1000, { forms: full, required: true })];
+ const placed = placeLabels(items, { width: 800, height: 400 });
+ expect(placed.get('sel')).toMatchObject({ form: 'full', displaced: false });
+ const other = placed.get('other')!;
+ expect(other.form === null || other.displaced || other.form === 'icon').toBe(true);
+ expect(noOverlaps(items, placed)).toBe(true);
+ });
+
+ it('a second required label falls back to a smaller form instead of covering the first', () => {
+ // A selected incident and the selected agent at the same station on a small canvas.
+ const incident = badge('incident', 200, 60, 1000, { forms: [{ form: 'full', w: 270, h: 20 }], required: true });
+ const agent = badge('agent', 205, 62, 990, {
+ forms: [
+ { form: 'full', w: 170, h: 46 },
+ { form: 'compact', w: 80, h: 22 },
+ ],
+ required: true,
+ });
+ const items = [incident, agent];
+ const placed = placeLabels(items, { width: 420, height: 90 });
+ expect(placed.get('incident')).toMatchObject({ form: 'full', displaced: false });
+ expect(placed.get('agent')?.form).toBe('compact');
+ expect(noOverlaps(items, placed)).toBe(true);
+ });
+
+ it('keeps a required label on screen even when nothing is free', () => {
+ const full: FormSize[] = [{ form: 'full', w: 160, h: 44 }];
+ const placed = placeLabels([badge('sel', 2, 400, 1000, { forms: full, required: true })], {
+ width: 300,
+ height: 400,
+ obstacles: [{ l: 0, t: 0, r: 300, b: 400 }],
+ });
+ const p = placed.get('sel')!;
+ expect(p.form).toBe('full');
+ expect(p.x).toBeGreaterThanOrEqual(80);
+ });
+
+ it('avoids obstacles (floating controls)', () => {
+ const placed = placeLabels([badge('a', 400, 30)], { width: 800, height: 400, obstacles: [{ l: 0, t: 0, r: 800, b: 46 }] });
+ const p = placed.get('a')!;
+ // 22 px tall above y = 30 would reach under the toolbar band: it moves sideways/down is not allowed, so it hides.
+ expect(p.form === null || p.y - 22 >= 46).toBe(true);
+ });
+
+ it('signs only shift sideways (never stacked) and hide when blocked', () => {
+ const sign: LabelItem = { id: 'zone', x: 790, y: 100, priority: 100, movable: false, slots: SIGN_SLOTS, forms: [{ form: 'full', w: 120, h: 20 }] };
+ const p = placeLabels([sign], { width: 800, height: 400 }).get('zone')!;
+ expect(p.form).toBe('full');
+ expect(p.y).toBe(100);
+ expect(p.x + 60).toBeLessThanOrEqual(796);
+ const blocked = placeLabels([sign, badge('a', 730, 100, 900, { forms: [{ form: 'compact', w: 400, h: 60 }] })], { width: 800, height: 400 });
+ expect(blocked.get('zone')!.form).toBeNull();
+ });
+
+ it('expands a hovered label in place, moving smaller neighbours instead', () => {
+ const full: FormSize[] = [
+ { form: 'full', w: 120, h: 44 },
+ { form: 'compact', w: 90, h: 22 },
+ ];
+ const items = [badge('hovered', 400, 200, 950, { forms: full, movable: false, eager: true }), badge('near', 420, 160, 400)];
+ const placed = placeLabels(items, { width: 800, height: 400 });
+ expect(placed.get('hovered')).toMatchObject({ form: 'full', x: 400, y: 200 });
+ expect(noOverlaps(items, placed)).toBe(true);
+ });
+
+ it('is stable: last frame’s placement is kept while it stays free', () => {
+ const items = [badge('a', 300, 200, 500), badge('b', 320, 200, 400)];
+ const first = placeLabels(items, { width: 800, height: 400 });
+ // Move b a little: it should keep the same slot rather than jump to another candidate.
+ const moved = [items[0]!, { ...items[1]!, x: 322 }];
+ const second = placeLabels(moved, { width: 800, height: 400, previous: first });
+ expect(second.get('b')!.slot).toBe(first.get('b')!.slot);
+ expect(second.get('b')!.form).toBe(first.get('b')!.form);
+ });
+});
diff --git a/apps/studio/src/scene/labelLayout.ts b/apps/studio/src/scene/labelLayout.ts
new file mode 100644
index 0000000..f38eb10
--- /dev/null
+++ b/apps/studio/src/scene/labelLayout.ts
@@ -0,0 +1,196 @@
+/**
+ * Collision-free placement for the DOM labels over the world (pure; screen px).
+ *
+ * Two passes, in priority order. First every label takes its smallest form
+ * (an icon for badges) at the nearest free slot around its anchor (on it,
+ * beside it, then stacked above), so as many as possible show. Then each
+ * grows into a more informative form if one fits beside its anchor. A label
+ * that fits nowhere is hidden (the roster and the Network view still list
+ * every agent); the selected label always shows, in full. Last frame's slot
+ * is tried first so labels do not jitter while characters walk.
+ */
+
+export type LabelForm = 'full' | 'compact' | 'icon';
+
+export interface FormSize {
+ form: LabelForm;
+ w: number;
+ h: number;
+}
+
+export interface LabelItem {
+ id: string;
+ /** Anchor (screen px): the label's bottom centre sits here when not displaced. */
+ x: number;
+ y: number;
+ /** Forms to try, most informative first. */
+ forms: readonly FormSize[];
+ /** Higher is placed first. */
+ priority: number;
+ /** Badges may move off their anchor (with a leader line); signs only show or hide. */
+ movable: boolean;
+ /** Restrict the slots tried (indices into SLOTS); default: all when movable, else only the anchor. */
+ slots?: readonly number[];
+ /** Always shown (the selection), even when it has to overlap. */
+ required?: boolean;
+ /** Claims its most informative form that fits before smaller labels are placed (hover, focus). */
+ eager?: boolean;
+}
+
+export interface Rect {
+ l: number;
+ t: number;
+ r: number;
+ b: number;
+}
+
+export interface Placement {
+ /** null = hidden. */
+ form: LabelForm | null;
+ /** Bottom centre of the placed label. */
+ x: number;
+ y: number;
+ /** Index into the slot list (0 = on the anchor). */
+ slot: number;
+ /** Moved away from its anchor: draw a leader line. */
+ displaced: boolean;
+}
+
+export interface PlaceOptions {
+ width: number;
+ height: number;
+ /** Gap kept between labels (px). */
+ gap?: number;
+ /** Keep labels this far inside the canvas edges. */
+ margin?: number;
+ /** Screen areas labels must not cover (floating controls). */
+ obstacles?: readonly Rect[];
+ /** Last frame's placements, for stability. */
+ previous?: ReadonlyMap;
+}
+
+/** Slot offsets in units of (label half-width + gap, label height + gap): on, beside, above. */
+const SLOTS: readonly (readonly [number, number])[] = [
+ [0, 0],
+ [1, 0],
+ [-1, 0],
+ [0, -1],
+ [1, -1],
+ [-1, -1],
+ [0, -2],
+ [1, -2],
+ [-1, -2],
+ [0.5, 0],
+ [-0.5, 0],
+];
+
+/** Slot rows, nearest first: on/beside the anchor, one row up, two rows up. */
+const TIERS: readonly (readonly number[])[] = [
+ [0, 9, 10, 1, 2],
+ [3, 4, 5],
+ [6, 7, 8],
+];
+const ALL_SLOTS = TIERS.flat();
+const ANCHOR_ONLY = [0];
+/** Signs: on the anchor, or shifted sideways (never stacked, no leader). */
+export const SIGN_SLOTS: readonly number[] = [0, 10, 9, 2, 1];
+
+const overlaps = (a: Rect, b: Rect, gap: number) => a.l < b.r + gap && a.r > b.l - gap && a.t < b.b + gap && a.b > b.t - gap;
+
+function rectAt(it: LabelItem, f: FormSize, slot: number, gap: number): { rect: Rect; x: number; y: number } {
+ const [sx, sy] = SLOTS[slot]!;
+ const x = it.x + sx * (f.w / 2 + gap);
+ const y = it.y + sy * (f.h + gap);
+ return { rect: { l: x - f.w / 2, r: x + f.w / 2, t: y - f.h, b: y }, x, y };
+}
+
+export function placeLabels(items: readonly LabelItem[], opts: PlaceOptions): Map {
+ const gap = opts.gap ?? 4;
+ const margin = opts.margin ?? 4;
+ const obstacles = opts.obstacles ?? [];
+ const placed = new Map();
+ /** Placed rectangles, kept flat for the overlap test (it runs for every candidate). */
+ const rects: { id: string; rect: Rect }[] = [];
+ const put = (id: string, hit: { rect: Rect; p: Placement }) => {
+ placed.set(id, hit);
+ const i = rects.findIndex((r) => r.id === id);
+ if (i >= 0) rects[i]!.rect = hit.rect;
+ else rects.push({ id, rect: hit.rect });
+ };
+ const inside = (r: Rect) => r.l >= margin && r.t >= margin && r.r <= opts.width - margin && r.b <= opts.height - margin;
+ const free = (r: Rect, self?: string) => {
+ if (!inside(r)) return false;
+ for (const o of obstacles) if (overlaps(r, o, gap)) return false;
+ for (const x of rects) if (x.id !== self && overlaps(r, x.rect, gap)) return false;
+ return true;
+ };
+
+ /** First free spot for one form: last frame's slot, then nearest slots first. */
+ const find = (it: LabelItem, fi: number, slots: readonly number[], self?: string) => {
+ const f = it.forms[fi]!;
+ const prev = opts.previous?.get(it.id);
+ const order = [...(prev && prev.form === f.form && slots.includes(prev.slot) ? [prev.slot] : []), ...slots];
+ for (const s of order) {
+ const c = rectAt(it, f, s, gap);
+ if (free(c.rect, self)) return { rect: c.rect, p: { form: f.form, x: c.x, y: c.y, slot: s, displaced: s !== 0 } };
+ }
+ return null;
+ };
+
+ const order = [...items].filter((it) => it.forms.length > 0).sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id));
+ const slotsOf = (it: LabelItem) => it.slots ?? (it.movable ? ALL_SLOTS : ANCHOR_ONLY);
+
+ // Phase 1: as many labels as possible, each in its smallest form. A required label takes its most
+ // informative form that fits anywhere, and overlaps (clamped on screen) only when none does.
+ for (const it of order) {
+ let hit = null as ReturnType;
+ if (it.required) {
+ for (let f = 0; f < it.forms.length && !hit; f++) hit = find(it, f, slotsOf(it));
+ } else {
+ const fi = it.forms.length - 1;
+ if (it.eager) for (let e = 0; e < fi && !hit; e++) hit = find(it, e, slotsOf(it));
+ hit ??= find(it, fi, slotsOf(it));
+ }
+ if (!hit && it.required) {
+ const f = it.forms[0]!;
+ // Keep it on screen: clamp into the canvas.
+ const x = Math.min(opts.width - margin - f.w / 2, Math.max(margin + f.w / 2, it.x));
+ const y = Math.min(opts.height - margin, Math.max(margin + f.h, it.y));
+ hit = { rect: { l: x - f.w / 2, r: x + f.w / 2, t: y - f.h, b: y }, p: { form: f.form, x, y, slot: 0, displaced: Math.abs(x - it.x) > 1 || Math.abs(y - it.y) > 1 } };
+ }
+ if (hit) put(it.id, hit);
+ }
+
+ // Phase 2: in priority order, grow each label into a more informative form near its anchor.
+ for (const it of order) {
+ const cur = placed.get(it.id);
+ if (!cur || it.required) continue;
+ const curIndex = it.forms.findIndex((f) => f.form === cur.p.form);
+ const near = slotsOf(it).filter((s) => TIERS[0]!.includes(s));
+ for (let fi = 0; fi < curIndex; fi++) {
+ const hit = find(it, fi, near, it.id);
+ if (hit) {
+ put(it.id, hit);
+ break;
+ }
+ }
+ }
+
+ const out = new Map();
+ for (const it of items) out.set(it.id, placed.get(it.id)?.p ?? { form: null, x: it.x, y: it.y, slot: 0, displaced: false });
+ return out;
+}
+
+/** True when no two shown labels overlap (used by tests and debug checks). */
+export function noOverlaps(items: readonly LabelItem[], placements: ReadonlyMap): boolean {
+ const rects: Rect[] = [];
+ for (const it of items) {
+ const p = placements.get(it.id);
+ if (!p?.form) continue;
+ const f = it.forms.find((x) => x.form === p.form)!;
+ const r = { l: p.x - f.w / 2, r: p.x + f.w / 2, t: p.y - f.h, b: p.y };
+ if (rects.some((o) => overlaps(r, o, 0))) return false;
+ rects.push(r);
+ }
+ return true;
+}
diff --git a/apps/studio/src/scene/labels.tsx b/apps/studio/src/scene/labels.tsx
new file mode 100644
index 0000000..4a24cae
--- /dev/null
+++ b/apps/studio/src/scene/labels.tsx
@@ -0,0 +1,413 @@
+'use client';
+import { useFrame, useThree } from '@react-three/fiber';
+import type { PresentationState } from '@runloop-world/world-engine';
+import { useEffect, useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties } from 'react';
+import { Vector3 } from 'three';
+import { STATE_BADGE, TONE_COLOR, type IconName } from './badges';
+import { useScene } from './context';
+import { Icon } from './Icon';
+import { SIGN_SLOTS, placeLabels, type FormSize, type LabelForm, type LabelItem, type Placement, type Rect } from './labelLayout';
+import styles from './scene.module.css';
+
+/**
+ * DOM labels over the canvas (agent badges, environment signs, station names,
+ * incident chips). They live in the page's React tree (accessible buttons,
+ * real text in the app's font); the scene registers what to show and where,
+ * and a projector places them every frame through refs, with no React
+ * updates per frame. Placement is collision-free (labelLayout.ts): badges
+ * collapse to one line or an icon, or step aside with a leader line.
+ */
+export type LabelContent =
+ | { kind: 'zone'; text: string; color: string }
+ | { kind: 'station'; text: string; color: string }
+ | {
+ kind: 'agent';
+ agentId: string;
+ name: string;
+ /** CSS colour (identity token). */
+ color: string;
+ icon: IconName;
+ state: PresentationState;
+ detail: string;
+ /** Running tool for the one-line badge ('' = none). */
+ short: string;
+ /** Reported usage, or null (not reported: nothing is shown). */
+ usage: string | null;
+ selected: boolean;
+ /** Finished subagent: shown quieter. */
+ dim: boolean;
+ }
+ | { kind: 'incident'; open: boolean; text: string; short: string; title: string; seq: number; selected: boolean };
+
+/** World-space anchor: a fixed point, or a function evaluated every frame. */
+export type Anchor = Vector3 | ((out: Vector3) => Vector3 | null);
+
+type Sizes = Partial>;
+
+export class LabelStore {
+ private specs = new Map();
+ private snap: readonly [string, LabelContent][] = [];
+ private listeners = new Set<() => void>();
+ readonly anchors = new Map();
+ readonly elements = new Map();
+ readonly leaders = new Map();
+ /** Measured size of each form (from hidden twins, kept current by a ResizeObserver). */
+ readonly sizes = new Map();
+ /** Label under the pointer or holding keyboard focus: shown in full. */
+ hovered: string | null = null;
+ focused: string | null = null;
+ /** Screen areas covered by floating controls, for the current canvas size. */
+ obstacles: (w: number, h: number) => Rect[] = () => [];
+ previous = new Map();
+
+ get(id: string): LabelContent | undefined {
+ return this.specs.get(id);
+ }
+
+ set(id: string, content: LabelContent, anchor: Anchor) {
+ this.specs.set(id, content);
+ this.anchors.set(id, anchor);
+ this.emit();
+ }
+
+ remove(id: string) {
+ if (!this.specs.delete(id)) return;
+ this.anchors.delete(id);
+ this.sizes.delete(id);
+ this.previous.delete(id);
+ this.emit();
+ }
+
+ subscribe = (fn: () => void) => {
+ this.listeners.add(fn);
+ return () => void this.listeners.delete(fn);
+ };
+
+ snapshot = () => this.snap;
+
+ private emit() {
+ this.snap = [...this.specs];
+ for (const fn of this.listeners) fn();
+ }
+}
+
+/** Show a label while the calling component is mounted (content compared by value). */
+export function useLabel(id: string, content: LabelContent | null, anchor: Anchor) {
+ const { labels } = useScene();
+ const key = content ? JSON.stringify(content) : '';
+ // Layout effects, so a synchronous badge update reaches the label layer in the same commit.
+ useLayoutEffect(() => {
+ if (!content) {
+ labels.remove(id);
+ return;
+ }
+ labels.set(id, content, anchor);
+ // `key` captures content by value; anchor objects are stable per caller.
+ }, [labels, id, key]);
+ useLayoutEffect(() => () => labels.remove(id), [labels, id]);
+}
+
+const AGENT_PRIORITY: Record = {
+ failed: 620,
+ awaiting_approval: 610,
+ working: 520,
+ waiting: 480,
+ moving: 430,
+ succeeded: 420,
+ idle: 400,
+ paused: 400,
+};
+
+/** How a label competes for space: priority, forms to try, and whether it may move. */
+export function labelRules(id: string, c: LabelContent, hovered: string | null, focused: string | null): Omit & { forms: LabelForm[] } {
+ const hot = id === hovered || id === focused;
+ switch (c.kind) {
+ case 'agent':
+ // Selected: always shown, in full when it fits. A selected incident (the event being inspected) is placed first.
+ if (c.selected) return { id, priority: 990, forms: ['full', 'compact'], movable: true, required: true };
+ // Hovered or focused: expand in place when there is room (see LabelProjector), so it never slides out from under the pointer.
+ if (hot) return { id, priority: 950, forms: ['full', 'compact', 'icon'], movable: false, eager: true };
+ return { id, priority: c.dim ? 300 : AGENT_PRIORITY[c.state], forms: ['compact', 'icon'], movable: true };
+ case 'incident':
+ if (c.selected) return { id, priority: 1000, forms: ['full'], movable: true, required: true };
+ if (hot) return { id, priority: 940, forms: ['full', 'compact'], movable: false, eager: true };
+ return c.open ? { id, priority: 560, forms: ['full', 'compact'], movable: true } : { id, priority: 60, forms: ['compact'], movable: true };
+ case 'station':
+ return { id, priority: 980, forms: ['full'], movable: true, eager: true };
+ case 'zone':
+ return { id, priority: 100, forms: ['full'], movable: false, slots: SIGN_SLOTS };
+ }
+}
+
+const v = new Vector3();
+
+/**
+ * Inside the Canvas: projects every label anchor to screen space each frame
+ * and places the labels without overlaps (labelLayout.ts). Mount it last.
+ */
+export function LabelProjector() {
+ const { labels } = useScene();
+ const camera = useThree((s) => s.camera);
+ const size = useThree((s) => s.size);
+ const hide = (id: string) => {
+ const el = labels.elements.get(id);
+ const leader = labels.leaders.get(id);
+ if (el) el.style.visibility = 'hidden';
+ if (leader) leader.style.visibility = 'hidden';
+ };
+ useFrame(() => {
+ camera.updateMatrixWorld();
+ const items: LabelItem[] = [];
+ const anchorsPx = new Map();
+ for (const id of labels.elements.keys()) {
+ const c = labels.get(id);
+ const a = labels.anchors.get(id);
+ const p = typeof a === 'function' ? a(v) : a ? v.copy(a) : null;
+ const sizes = labels.sizes.get(id);
+ if (!c || !p || !sizes) {
+ hide(id);
+ continue;
+ }
+ p.project(camera);
+ const x = ((p.x + 1) / 2) * size.width;
+ const y = ((1 - p.y) / 2) * size.height;
+ if (p.z > 1 || x < -40 || y < -40 || x > size.width + 40 || y > size.height + 40) {
+ hide(id);
+ continue;
+ }
+ const rules = labelRules(id, c, labels.hovered, labels.focused);
+ // A hovered/focused label grows from where it already is: full if it fits there, else as it was.
+ const prev = id === labels.hovered || id === labels.focused ? labels.previous.get(id) : undefined;
+ if (prev?.form) rules.forms = rules.forms.filter((f, i) => i === 0 || f === prev.form);
+ const forms: FormSize[] = [];
+ for (const f of rules.forms) {
+ const s = sizes[f];
+ if (s && s.w > 0) forms.push({ form: f, w: s.w, h: s.h });
+ }
+ anchorsPx.set(id, { x, y });
+ items.push({ ...rules, forms, x: prev?.form ? prev.x : x, y: prev?.form ? prev.y : y });
+ }
+
+ const placed = placeLabels(items, {
+ width: size.width,
+ height: size.height,
+ gap: 4,
+ margin: 4,
+ obstacles: labels.obstacles(size.width, size.height),
+ previous: labels.previous,
+ });
+ labels.previous = placed;
+
+ for (const it of items) {
+ const el = labels.elements.get(it.id)!;
+ const p = placed.get(it.id);
+ const leader = labels.leaders.get(it.id);
+ if (!p || !p.form) {
+ hide(it.id);
+ continue;
+ }
+ if (el.dataset.form !== p.form) el.dataset.form = p.form;
+ el.style.visibility = 'visible';
+ el.style.transform = `translate3d(${p.x.toFixed(1)}px, ${p.y.toFixed(1)}px, 0) translate(-50%, -100%)`;
+ el.style.zIndex = String(it.priority);
+ if (leader) {
+ const a = anchorsPx.get(it.id)!;
+ const dx = p.x - a.x;
+ const dy = p.y - a.y;
+ const len = Math.hypot(dx, dy);
+ if (len > 6 && labels.get(it.id)?.kind !== 'zone') {
+ leader.style.visibility = 'visible';
+ leader.style.width = `${(len - 2).toFixed(1)}px`;
+ leader.style.transform = `translate3d(${a.x.toFixed(1)}px, ${a.y.toFixed(1)}px, 0) rotate(${Math.atan2(dy, dx).toFixed(4)}rad)`;
+ } else {
+ leader.style.visibility = 'hidden';
+ }
+ }
+ }
+ });
+ return null;
+}
+
+/** Outside the Canvas: renders the labels as DOM, plus hidden twins that measure every form. */
+export function LabelLayer({
+ store,
+ onSelectAgent,
+ onSelectEvent,
+}: {
+ store: LabelStore;
+ onSelectAgent: (id: string | null) => void;
+ onSelectEvent?: (seq: number) => void;
+}) {
+ const list = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
+ return (
+
+ {list.map(([id]) => (
+
void (el ? store.leaders.set(id, el) : store.leaders.delete(id))} />
+ ))}
+ {list.map(([id, c]) => (
+
{
+ if (el) store.elements.set(id, el);
+ else store.elements.delete(id);
+ }}
+ >
+
+
+ ))}
+
+
+ );
+}
+
+/** Hidden copies of every label with all forms laid out, so their sizes are known before placement. */
+function Measure({ list, store }: { list: readonly [string, LabelContent][]; store: LabelStore }) {
+ const root = useRef
(null);
+ useEffect(() => {
+ const el = root.current;
+ if (!el || typeof ResizeObserver === 'undefined') return;
+ const read = () => {
+ for (const item of el.querySelectorAll('[data-measure-id]')) {
+ const id = item.dataset.measureId!;
+ const sizes: Sizes = {};
+ for (const part of item.querySelectorAll('[data-part]')) {
+ sizes[part.dataset.part as LabelForm] = { w: part.offsetWidth, h: part.offsetHeight };
+ }
+ store.sizes.set(id, sizes);
+ }
+ };
+ read();
+ const ro = new ResizeObserver(read);
+ for (const part of el.querySelectorAll('[data-part]')) ro.observe(part);
+ return () => ro.disconnect();
+ }, [list, store]);
+ return (
+
+ {list.map(([id, c]) => (
+
+
+
+ ))}
+
+ );
+}
+
+function LabelBody({
+ id,
+ content: c,
+ store,
+ measure,
+ onSelectAgent,
+ onSelectEvent,
+}: {
+ id: string;
+ content: LabelContent;
+ store: LabelStore;
+ measure?: boolean;
+ onSelectAgent?: (id: string | null) => void;
+ onSelectEvent?: (seq: number) => void;
+}) {
+ // Hover and keyboard focus expand a label to its full form (the projector reads these every frame).
+ const hot = measure
+ ? {}
+ : {
+ onPointerEnter: () => void (store.hovered = id),
+ onPointerLeave: () => void (store.hovered === id && (store.hovered = null)),
+ onFocus: () => void (store.focused = id),
+ onBlur: () => void (store.focused === id && (store.focused = null)),
+ };
+ const tab = measure ? -1 : undefined;
+ switch (c.kind) {
+ case 'zone':
+ return (
+
+
+ {c.text}
+
+ );
+ case 'station':
+ return (
+
+ {c.text}
+
+ );
+ case 'incident':
+ return (
+ {
+ e.stopPropagation();
+ onSelectEvent?.(c.seq);
+ }}
+ {...hot}
+ >
+
+
+ {c.text}
+
+
+
+ {c.short}
+
+
+ );
+ case 'agent': {
+ const badge = STATE_BADGE[c.state];
+ const vars = { '--agent': c.color, '--tone': TONE_COLOR[badge.tone] } as CSSProperties;
+ return (
+ {
+ e.stopPropagation();
+ onSelectAgent?.(c.agentId);
+ }}
+ aria-pressed={measure ? undefined : c.selected}
+ aria-label={`${c.name}: ${c.detail}${c.usage ? `, ${c.usage}` : ''}`}
+ title={`${c.name} · ${c.detail}${c.usage ? ` · ${c.usage}` : ''}`}
+ {...hot}
+ >
+
+
+
+ {c.name}
+
+
+
+ {c.detail}
+
+ {c.usage && {c.usage} }
+
+
+
+ {c.name}
+
+
+ {c.short && {c.short} }
+
+
+
+
+
+
+
+
+
+ );
+ }
+ }
+}
diff --git a/apps/studio/src/scene/layout.test.ts b/apps/studio/src/scene/layout.test.ts
new file mode 100644
index 0000000..6043953
--- /dev/null
+++ b/apps/studio/src/scene/layout.test.ts
@@ -0,0 +1,130 @@
+import { readFileSync } from 'node:fs';
+import { ReplayIndex } from '@runloop-world/replay';
+import { codingMissionFixture, runBuilder } from '@runloop-world/testkit';
+import { loadWorld, type Cell } from '@runloop-world/world-engine';
+import { describe, expect, it } from 'vitest';
+import { badgeDetail, STATE_BADGE } from './badges';
+import {
+ FLOOR_Y,
+ ROOM_Y,
+ classifyWalls,
+ floorTiles,
+ glowPaths,
+ heightAt,
+ hubRing,
+ incidentStationId,
+ pathPoints,
+ stationFront,
+ zoneSignCell,
+} from './layout';
+
+const world = loadWorld(JSON.parse(readFileSync(new URL('../../../../worlds/research-campus/world.json', import.meta.url), 'utf8')));
+const adjacent = (a: Cell, b: Cell) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
+
+describe('scene layout (derived from the world definition)', () => {
+ it('classifies every wall cell; north/west room edges are tall back walls, south/east are curbs', () => {
+ const walls = classifyWalls(world);
+ const hashes = world.def.grid.join('').split('').filter((c) => c === '#').length;
+ expect(walls).toHaveLength(hashes);
+ const at = (x: number, y: number) => walls.find((w) => w.cell[0] === x && w.cell[1] === y)!;
+ expect(at(4, 1).kind).toBe('back'); // research north wall
+ expect(at(1, 4).kind).toBe('back'); // research west wall
+ expect(at(4, 7).kind).toBe('curb'); // research south wall
+ expect(at(9, 4).kind).toBe('curb'); // research east wall
+ expect(at(1, 7).kind).toBe('back'); // SW corner continues the west wall
+ });
+
+ it('puts a floor tile on every non-wall cell', () => {
+ const cells = world.width * world.height;
+ expect(floorTiles(world).length + classifyWalls(world).length).toBe(cells);
+ });
+
+ it('builds glow paths along walkable, contiguous cells from the hub', () => {
+ const ring = hubRing(world);
+ expect(ring.length).toBeGreaterThan(0);
+ const paths = glowPaths(world);
+ const doors = world.def.zones.filter((z) => z.door && z.kind !== 'hub');
+ expect(paths.filter((p) => !p.branch && p.key.startsWith('hub>')).length).toBe(doors.length + 1); // + the gate
+ for (const p of paths) {
+ p.cells.forEach((c, i) => {
+ expect(world.isWalkable(c[0], c[1])).toBe(true);
+ if (i > 0) expect(adjacent(p.cells[i - 1]!, c)).toBe(true);
+ });
+ if (!p.branch) expect(ring.some((r) => r[0] === p.cells[0]![0] && r[1] === p.cells[0]![1])).toBe(true);
+ }
+ });
+
+ it('steps between corridor and room heights at the cell boundary', () => {
+ expect(heightAt(world, 7, 9)).toBeCloseTo(FLOOR_Y);
+ expect(heightAt(world, 4, 4)).toBeCloseTo(ROOM_Y);
+ const mid = heightAt(world, 7, 7.5); // between door (7,7) and corridor (7,8)
+ expect(mid).toBeGreaterThan(FLOOR_Y);
+ expect(mid).toBeLessThan(ROOM_Y);
+ // Path polylines get a vertical riser where the height changes.
+ const pts = pathPoints(world, [[7, 8], [7, 7]], 0);
+ expect(pts).toHaveLength(4);
+ });
+
+ it('puts each environment sign at the room corner away from the Atrium', () => {
+ const at = (id: string) => zoneSignCell(world, world.zonesById[id]!);
+ expect(at('research')).toEqual([0.5, 0.5]); // north-west: top of the iso view
+ expect(at('build')).toEqual([22.5, 0.5]);
+ expect(at('test')).toEqual([0.5, 18.5]);
+ expect(at('deploy')).toEqual([22.5, 18.5]);
+ });
+
+ it('faces stations towards their first slot', () => {
+ expect(stationFront(world.stationsById['archive']!)).toEqual([0, 1]);
+ expect(stationFront(world.stationsById['testrig']!)).toEqual([0, -1]);
+ });
+});
+
+describe('scene derivations from Layer 1/2 state', () => {
+ const events = codingMissionFixture();
+ const index = new ReplayIndex(world, events);
+ const seqOf = (pred: (e: (typeof events)[number]) => boolean) => events.find(pred)!.sequence;
+
+ it('places the failing test incident at the station the tester used', () => {
+ const failSeq = seqOf((e) => e.type === 'tool.call_completed' && e.payload.status === 'error');
+ const later = index.stateAt(failSeq + 4); // tester has walked away (blocked → home)
+ const inc = later.exec.incidents[0]!;
+ // Without a remembered station it falls back to the first station the rules map the task kind to.
+ expect(incidentStationId(world, later, inc, new Map())).toBe('probe');
+ const cache = new Map();
+ expect(incidentStationId(world, index.stateAt(failSeq), inc, cache)).toBe('probe');
+ expect(cache.get(inc.eventId)).toBe('probe');
+ });
+
+ it('places a traced failing call by its tool category (a failing Bash test run → the Test Chamber)', () => {
+ const b = runBuilder({ namespace: 0x5f });
+ b.add('run.started', {
+ title: 'Claude Code · demo',
+ missionId: 'claude-code',
+ worldId: 'research-campus',
+ adapter: { id: 'claude-code', kind: 'external', label: 'Claude Code' },
+ mode: 'traced',
+ source: { kind: 'claude-code', sessionId: 's', segment: 1, projectLabel: 'demo' },
+ });
+ b.add('agent.registered', { agentId: 'main', name: 'Claude', role: 'main agent', model: null, tools: [], embodied: true, characterPreset: 'claude' });
+ b.add('task.created', { taskId: 'turn-1', title: 'Turn 1', kind: 'turn', dependsOn: [] });
+ b.add('task.started', { taskId: 'turn-1', agentId: 'main' });
+ b.add('tool.call_started', { callId: 'c1', agentId: 'main', taskId: 'turn-1', tool: 'Bash', category: 'test', inputSummary: 'node --test' });
+ b.add('tool.call_completed', { callId: 'c1', status: 'error', durationMs: 120, outputSummary: 'Exit code 1' });
+ const state = new ReplayIndex(world, b.events).latest();
+ const inc = state.exec.incidents[0]!;
+ // Not remembered (the character moved on): the category rule decides, not the task kind ("turn" has no rule).
+ const where = incidentStationId(world, { ...state, world: { ...state.world, characters: {} } }, inc, new Map());
+ expect(world.stationsById[where!]!.kind).toBe('test');
+ });
+
+ it('badge text comes from the state (icon + label for every state, tool / reason detail)', () => {
+ for (const b of Object.values(STATE_BADGE)) {
+ expect(b.label.length).toBeGreaterThan(0);
+ expect(b.icon.length).toBeGreaterThan(0);
+ }
+ const blocked = index.stateAt(seqOf((e) => e.type === 'agent.state_changed' && e.payload.state === 'waiting'));
+ expect(badgeDetail('waiting', 'tester', blocked, world)).toBe('Waiting · for a fix');
+ const s = index.stateAt(seqOf((e) => e.type === 'tool.call_started' && e.payload.tool === 'run_command'));
+ expect(badgeDetail('working', 'tester', s, world)).toBe('Working · run_command');
+ });
+});
diff --git a/apps/studio/src/scene/layout.ts b/apps/studio/src/scene/layout.ts
new file mode 100644
index 0000000..90f59a8
--- /dev/null
+++ b/apps/studio/src/scene/layout.ts
@@ -0,0 +1,280 @@
+import type { Incident } from '@runloop-world/core';
+import type { RunState } from '@runloop-world/replay';
+import {
+ bfs,
+ gridToScene,
+ matchActivityRule,
+ type Cell,
+ type CompiledWorld,
+ type Station,
+ type Zone,
+} from '@runloop-world/world-engine';
+
+/**
+ * Pure layout helpers: everything the scene draws is derived from the world
+ * definition here (no hard-coded map). Grid space: x = column, y = row.
+ */
+
+/** Top of corridor floor tiles. */
+export const FLOOR_Y = 0.06;
+/** Top of raised room floors. */
+export const ROOM_Y = 0.16;
+/** Height of a back (north/west) room wall above the room floor. */
+export const BACK_WALL_H = 1.3;
+/** Height of a front (south/east) curb above the room floor. */
+export const CURB_H = 0.26;
+
+const DIRS: readonly Cell[] = [
+ [0, -1],
+ [1, 0],
+ [0, 1],
+ [-1, 0],
+];
+const key = (x: number, y: number) => `${x},${y}`;
+
+const inRect = (z: Zone, x: number, y: number) =>
+ x >= z.rect[0] && y >= z.rect[1] && x < z.rect[0] + z.rect[2] && y < z.rect[1] + z.rect[3];
+
+/** The room (non-hub zone) containing a cell, if any. */
+export function roomAt(world: CompiledWorld, x: number, y: number): Zone | null {
+ for (const z of world.def.zones) if (z.kind !== 'hub' && inRect(z, x, y)) return z;
+ return null;
+}
+
+/** The zone (hub included) containing a cell, if any. */
+export function zoneAt(world: CompiledWorld, x: number, y: number): Zone | null {
+ return world.def.zones.find((z) => inRect(z, x, y)) ?? null;
+}
+
+export const isWall = (world: CompiledWorld, x: number, y: number) => world.def.grid[y]?.[x] === '#';
+
+/** Floor height of a cell (rooms are raised platforms). */
+export function cellTop(world: CompiledWorld, x: number, y: number): number {
+ return roomAt(world, x, y) ? ROOM_Y : FLOOR_Y;
+}
+
+const smooth = (t: number) => {
+ const s = Math.min(1, Math.max(0, (t - 0.3) / 0.4));
+ return s * s * (3 - 2 * s);
+};
+
+/** Floor height at a continuous grid position (steps up/down at the cell boundary). */
+export function heightAt(world: CompiledWorld, gx: number, gy: number): number {
+ const x0 = Math.floor(gx);
+ const y0 = Math.floor(gy);
+ const fx = gx - x0;
+ const fy = gy - y0;
+ const h = (x: number, y: number) => cellTop(world, Math.min(world.width - 1, x), Math.min(world.height - 1, y));
+ const top = h(x0, y0) + (h(x0 + 1, y0) - h(x0, y0)) * smooth(fx);
+ const bottom = h(x0, y0 + 1) + (h(x0 + 1, y0 + 1) - h(x0, y0 + 1)) * smooth(fx);
+ return top + (bottom - top) * smooth(fy);
+}
+
+/** Scene position [x, y, z] of a (continuous) grid position at height y. */
+export function toScene(world: CompiledWorld, gx: number, gy: number, y = 0): [number, number, number] {
+ const p = gridToScene(gx, gy, world);
+ return [p.x, y, p.z];
+}
+
+export interface WallCell {
+ cell: Cell;
+ /** 'back' = tall wall on a room's north/west edge; 'curb' = low wall elsewhere (cutaway). */
+ kind: 'back' | 'curb';
+ /** Room the wall belongs to (null for free-standing walls). */
+ zone: Zone | null;
+ /** Which edge of the room it sits on (back walls get panels on their inner face). */
+ side: 'n' | 'w' | 's' | 'e' | null;
+}
+
+/** Classify every '#' cell for a cutaway look from the south-east. */
+export function classifyWalls(world: CompiledWorld): WallCell[] {
+ const out: WallCell[] = [];
+ world.def.grid.forEach((row, y) => {
+ for (let x = 0; x < row.length; x++) {
+ if (row[x] !== '#') continue;
+ const zone = roomAt(world, x, y);
+ if (!zone) {
+ out.push({ cell: [x, y], kind: 'curb', zone: null, side: null });
+ continue;
+ }
+ const [rx, ry, rw, rh] = zone.rect;
+ const side = y === ry ? 'n' : x === rx ? 'w' : y === ry + rh - 1 ? 's' : x === rx + rw - 1 ? 'e' : null;
+ out.push({ cell: [x, y], kind: side === 'n' || side === 'w' ? 'back' : 'curb', zone, side });
+ }
+ });
+ return out;
+}
+
+export interface FloorTile {
+ cell: Cell;
+ zone: Zone | null;
+ top: number;
+}
+
+/** One tile per non-wall cell (stations, props and the hub core stand on tiles). */
+export function floorTiles(world: CompiledWorld): FloorTile[] {
+ const out: FloorTile[] = [];
+ world.def.grid.forEach((row, y) => {
+ for (let x = 0; x < row.length; x++) {
+ if (row[x] === '#') continue;
+ out.push({ cell: [x, y], zone: zoneAt(world, x, y), top: cellTop(world, x, y) });
+ }
+ });
+ return out;
+}
+
+export interface GlowPath {
+ key: string;
+ color: string;
+ cells: Cell[];
+ /** Branches run from a room door to its stations (drawn fainter). */
+ branch: boolean;
+}
+
+/** Walkable cells around the hub core, in a fixed order. */
+export function hubRing(world: CompiledWorld): Cell[] {
+ const core = world.def.hub.core;
+ const coreSet = new Set(core.map(([x, y]) => key(x, y)));
+ const seen = new Set();
+ const ring: Cell[] = [];
+ for (const [cx, cy] of core) {
+ for (const [dx, dy] of DIRS) {
+ const x = cx + dx;
+ const y = cy + dy;
+ const k = key(x, y);
+ if (coreSet.has(k) || seen.has(k) || !world.isWalkable(x, y)) continue;
+ seen.add(k);
+ ring.push([x, y]);
+ }
+ }
+ if (ring.length === 0) {
+ const c: Cell = [Math.round(world.def.hub.center[0]), Math.round(world.def.hub.center[1])];
+ if (world.isWalkable(c[0], c[1])) ring.push(c);
+ }
+ return ring;
+}
+
+/**
+ * Conveyor-like glow paths: hub → each room door, hub → stations in rooms
+ * without a door (e.g. the gate), and door → each station in that room.
+ */
+export function glowPaths(world: CompiledWorld): GlowPath[] {
+ const ring = hubRing(world);
+ const out: GlowPath[] = [];
+ const shortestFromHub = (to: Cell): Cell[] | null => {
+ let best: Cell[] | null = null;
+ for (const start of ring) {
+ const p = bfs(world, start, to);
+ if (p && (!best || p.length < best.length)) best = p;
+ }
+ return best;
+ };
+
+ for (const zone of world.def.zones) {
+ if (zone.kind === 'hub' || !zone.door) continue;
+ const main = shortestFromHub(zone.door);
+ if (main) out.push({ key: `hub>${zone.id}`, color: zone.color, cells: [...main], branch: false });
+ for (const s of world.def.stations) {
+ if (s.zoneId !== zone.id) continue;
+ const p = bfs(world, zone.door, s.slots[0]!);
+ if (p && p.length > 1) out.push({ key: `${zone.id}>${s.id}`, color: zone.color, cells: [...p], branch: true });
+ }
+ }
+ for (const s of world.def.stations) {
+ const zone = world.zonesById[s.zoneId];
+ if (zone && zone.kind !== 'hub' && zone.door) continue;
+ const p = shortestFromHub(s.slots[0]!);
+ if (p) out.push({ key: `hub>${s.id}`, color: zone?.color ?? '#9297a2', cells: [...p], branch: false });
+ }
+ return out;
+}
+
+/**
+ * Scene-space polyline for a path of cells, `lift` above the floor. Height
+ * changes (corridor ↔ room) become a vertical riser at the cell boundary.
+ */
+export function pathPoints(world: CompiledWorld, cells: readonly Cell[], lift: number): [number, number, number][] {
+ const pts: [number, number, number][] = [];
+ cells.forEach((c, i) => {
+ const h = cellTop(world, c[0], c[1]) + lift;
+ if (i > 0) {
+ const prev = cells[i - 1]!;
+ const hp = cellTop(world, prev[0], prev[1]) + lift;
+ if (hp !== h) {
+ const mx = (prev[0] + c[0]) / 2;
+ const my = (prev[1] + c[1]) / 2;
+ pts.push(toScene(world, mx, my, hp), toScene(world, mx, my, h));
+ }
+ }
+ pts.push(toScene(world, c[0], c[1], h));
+ });
+ return pts;
+}
+
+const FACING: Record = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
+
+/** Grid direction a station's front faces (towards its first slot; falls back to `facing`). */
+export function stationFront(s: Station): Cell {
+ const slot = s.slots[0]!;
+ const dx = slot[0] - s.cell[0];
+ const dy = slot[1] - s.cell[1];
+ if (Math.abs(dx) + Math.abs(dy) === 1) return [dx, dy];
+ return FACING[s.facing];
+}
+
+/** Y rotation that turns a model whose front is +z towards a grid direction. */
+export const yawFor = (dx: number, dy: number) => Math.atan2(dx, dy);
+
+/** Y rotation for a world-engine heading (radians in grid space, 0 = +x, π/2 = +y). */
+export const yawForHeading = (heading: number) => Math.atan2(Math.cos(heading), Math.sin(heading));
+
+/** The zone colour behind a station (the scene desaturates it; see palette.ts). */
+export function stationColor(world: CompiledWorld, s: Station): string {
+ return world.zonesById[s.zoneId]?.color ?? '#9297a2';
+}
+
+/**
+ * Station an incident marker belongs to. Prefers the station the agent was
+ * working at for that task (remembered in `cache`), else the station the
+ * activity rules give the failing call's tool category (traced runs), else
+ * the first station the rules map the task kind to.
+ */
+export function incidentStationId(
+ world: CompiledWorld,
+ state: RunState,
+ inc: Incident,
+ cache: Map,
+): string | null {
+ const hit = cache.get(inc.eventId);
+ if (hit) return hit;
+ const ch = inc.agentId ? state.world.characters[inc.agentId] : undefined;
+ if (ch && ch.stationId && (inc.taskId === null || ch.taskId === inc.taskId)) {
+ cache.set(inc.eventId, ch.stationId);
+ return ch.stationId;
+ }
+ const exec = state.exec;
+ const call = inc.kind === 'tool_error' || inc.kind === 'tool_failed' ? exec.toolCalls.get(inc.refId) : undefined;
+ const agent = inc.agentId ? exec.agents[inc.agentId] : undefined;
+ const category = call?.category ?? (agent?.lastCategory && agent.lastCategory.taskId === inc.taskId ? agent.lastCategory.category : null);
+ const task = inc.taskId ? exec.tasks[inc.taskId] : undefined;
+ if (!task && !category) return null;
+ const kind = matchActivityRule(world.def.activityRules, { taskKind: task?.kind ?? null, toolCategory: category, permission: false });
+ return (kind && world.stationsByKind[kind]?.[0]?.id) || null;
+}
+
+/** Where a zone's sign stands: the room corner farthest from the hub (the hub's own sign sits at its front corner). */
+export function zoneSignCell(world: CompiledWorld, zone: Zone): [number, number] {
+ const [rx, ry, rw, rh] = zone.rect;
+ const [hx, hy] = world.def.hub.center;
+ if (zone.kind === 'hub') return [rx + rw - 0.5, ry + rh - 0.5];
+ const cx = rx + rw / 2;
+ const cy = ry + rh / 2;
+ return [cx < hx ? rx - 0.5 : rx + rw - 0.5, cy < hy ? ry - 0.5 : ry + rh - 0.5];
+}
+
+/** Cheap deterministic per-cell noise in [0, 1). */
+export function cellNoise(x: number, y: number): number {
+ let h = (x * 374761393 + y * 668265263) | 0;
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
+}
diff --git a/apps/studio/src/scene/palette.test.ts b/apps/studio/src/scene/palette.test.ts
new file mode 100644
index 0000000..93cba5b
--- /dev/null
+++ b/apps/studio/src/scene/palette.test.ts
@@ -0,0 +1,65 @@
+import { readFileSync } from 'node:fs';
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture } from '@runloop-world/testkit';
+import { Color } from 'three';
+import { describe, expect, it } from 'vitest';
+import { DARK_TOKENS, buildPalette, identityHex, readTokens, zoneFloor } from './palette';
+
+/** Pull `--name: value;` declarations out of one block of tokens.css. */
+function tokensOf(css: string, selector: string): Record {
+ const start = css.indexOf(selector);
+ const block = css.slice(css.indexOf('{', start) + 1, css.indexOf('\n}', start));
+ const out: Record = {};
+ for (const m of block.matchAll(/(--rl-[\w-]+):\s*([^;]+);/g)) out[m[1]!] = m[2]!.trim();
+ return out;
+}
+
+const css = readFileSync(new URL('../app/tokens.css', import.meta.url), 'utf8');
+const dark = tokensOf(css, ':root {');
+const light = tokensOf(css, ":root[data-theme='light']");
+const sat = (c: string) => {
+ const hsl = { h: 0, s: 0, l: 0 };
+ new Color(c).getHSL(hsl);
+ return hsl.s;
+};
+
+describe('scene palette from the design tokens', () => {
+ it('matches tokens.css (the scene defaults are the dark tokens)', () => {
+ const t = readTokens((n) => dark[n] ?? '');
+ expect(t).toEqual(DARK_TOKENS);
+ });
+
+ it('follows the theme: light tokens give a light world, with lime still reserved for active execution', () => {
+ const p = buildPalette(readTokens((n) => light[n] ?? dark[n] ?? ''));
+ expect(p.theme).toBe('light');
+ expect(p.active).toBe(light['--rl-lime']);
+ expect(p.glow).toBe(false);
+ const d = buildPalette(DARK_TOKENS);
+ expect(d.theme).toBe('dark');
+ expect(d.active).toBe('#b8f36a');
+ expect(d.glow).toBe(true);
+ });
+
+ it('never uses lime for architecture, outcomes or identity', () => {
+ const p = buildPalette(DARK_TOKENS);
+ const lime = p.active.toLowerCase();
+ const others = [p.slabTop, p.slabSide, p.corridor, p.room, p.wall, p.wallCap, p.curb, p.inlay, p.metal, p.success, p.warning, p.failure, ...p.identity];
+ expect(others.map((c) => c.toLowerCase())).not.toContain(lime);
+ });
+
+ it('keeps zone floors desaturated (quiet tints of graphite)', () => {
+ const p = buildPalette(DARK_TOKENS);
+ for (const zone of ['#38bdf8', '#818cf8', '#34d399', '#c084fc', '#f59e0b']) {
+ expect(sat(zoneFloor(p, zone))).toBeLessThan(sat(zone) * 0.5);
+ }
+ });
+
+ it('gives each embodied agent the same identity hue as the timeline (state/identity.ts)', () => {
+ const p = buildPalette(DARK_TOKENS);
+ const exec = foldExecution(codingMissionFixture());
+ expect(identityHex(p, exec, 'researcher')).toBe(DARK_TOKENS.identity[0]);
+ expect(identityHex(p, exec, 'builder')).toBe(DARK_TOKENS.identity[1]);
+ expect(identityHex(p, exec, 'tester')).toBe(DARK_TOKENS.identity[2]);
+ expect(identityHex(p, exec, 'coordinator')).toBe(DARK_TOKENS.identityNeutral);
+ });
+});
diff --git a/apps/studio/src/scene/palette.ts b/apps/studio/src/scene/palette.ts
new file mode 100644
index 0000000..2438461
--- /dev/null
+++ b/apps/studio/src/scene/palette.ts
@@ -0,0 +1,282 @@
+import type { ExecState } from '@runloop-world/core';
+import { Color } from 'three';
+import { IDENTITY_HUES, identityIndex } from '../state/identity';
+
+/**
+ * Scene colours, derived from the design tokens in app/tokens.css (the only
+ * colour source). The scene reads the tokens from the document, so the world
+ * follows the theme; tests and SSR get the dark defaults below.
+ *
+ * Rules (docs/design/design-system.md, "The 3D world"): obsidian and graphite
+ * architecture, desaturated zone tints, lime only for active execution,
+ * semantic colours for outcomes, glow only on state-carrying elements.
+ */
+
+export type SceneTheme = 'dark' | 'light';
+
+/** Token values the scene uses (CSS colour strings). */
+export interface SceneTokens {
+ obsidian: string;
+ graphite: string;
+ slate: string;
+ slateHi: string;
+ lime: string;
+ ice: string;
+ muted: string;
+ success: string;
+ warning: string;
+ failure: string;
+ worldBg: string;
+ identity: string[];
+ identityNeutral: string;
+}
+
+const TOKEN_NAMES: Record, string> = {
+ obsidian: '--rl-obsidian',
+ graphite: '--rl-graphite',
+ slate: '--rl-slate',
+ slateHi: '--rl-slate-hi',
+ lime: '--rl-lime',
+ ice: '--rl-ice',
+ muted: '--rl-muted',
+ success: '--rl-success',
+ warning: '--rl-warning',
+ failure: '--rl-failure',
+ worldBg: '--rl-world-bg',
+ identityNeutral: '--rl-id-neutral',
+};
+
+/** Dark-theme token values, used when no document is available. Keep in sync with tokens.css. */
+export const DARK_TOKENS: SceneTokens = {
+ obsidian: '#101114',
+ graphite: '#1b1d22',
+ slate: '#292c33',
+ slateHi: '#33363e',
+ lime: '#b8f36a',
+ ice: '#e8ebf0',
+ muted: '#9297a2',
+ success: '#48c78e',
+ warning: '#f5b84b',
+ failure: '#ff6b76',
+ worldBg: '#0c0d10',
+ identity: ['#5aa9f5', '#e0946a', '#43c6d9', '#a08cf9', '#e67ccb', '#8fa4bf', '#c38bd9', '#cdb592'],
+ identityNeutral: '#9297a2',
+};
+
+/** Read the scene tokens through a CSS custom-property getter (missing values fall back to dark). */
+export function readTokens(get: (name: string) => string): SceneTokens {
+ const out = { ...DARK_TOKENS, identity: [...DARK_TOKENS.identity] };
+ for (const [key, name] of Object.entries(TOKEN_NAMES) as [keyof typeof TOKEN_NAMES, string][]) {
+ const v = get(name).trim();
+ if (v) out[key] = v;
+ }
+ for (let i = 0; i < IDENTITY_HUES; i++) {
+ const v = get(`--rl-id-${i + 1}`).trim();
+ if (v) out.identity[i] = v;
+ }
+ return out;
+}
+
+/** Current tokens from the document (dark defaults without one). */
+export function documentTokens(): SceneTokens {
+ if (typeof document === 'undefined') return DARK_TOKENS;
+ const style = getComputedStyle(document.documentElement);
+ return readTokens((name) => style.getPropertyValue(name));
+}
+
+export interface SceneLights {
+ ambient: number;
+ hemiSky: string;
+ hemiGround: string;
+ hemi: number;
+ key: number;
+ keyColor: string;
+ fill: number;
+ fillColor: string;
+ env: number;
+}
+
+export interface ScenePalette {
+ theme: SceneTheme;
+ tokens: SceneTokens;
+ /** Board base slab. */
+ slabTop: string;
+ slabSide: string;
+ /** Hairline around the slab (tonal, not emissive). */
+ slabEdge: string;
+ /** Corridor and room floors (rooms are tinted per zone, see zoneFloor). */
+ corridor: string;
+ room: string;
+ wall: string;
+ wallCap: string;
+ curb: string;
+ /** Path inlays in the corridor floor. */
+ inlay: string;
+ metal: string;
+ metalDark: string;
+ metalLight: string;
+ /** Machine screens that carry no state right now (unlit glass). */
+ screenOff: string;
+ robotShell: string;
+ robotJoint: string;
+ visor: string;
+ /** Active execution only: working agent ring, running station. */
+ active: string;
+ success: string;
+ warning: string;
+ failure: string;
+ /** Resolved incidents, idle details. */
+ muted: string;
+ /** Neutral light for "occupied" screens and selection rings. */
+ ice: string;
+ identity: readonly string[];
+ identityNeutral: string;
+ lights: SceneLights;
+ /** Emissive glow (and bloom) is allowed: dark theme only, where light reads as light. */
+ glow: boolean;
+}
+
+const luminance = (c: string) => {
+ const col = new Color(c);
+ return 0.2126 * col.r + 0.7152 * col.g + 0.0722 * col.b;
+};
+
+/** Mix two colours (t = 0 → a, t = 1 → b) and return a hex string. */
+export function mixHex(a: string, b: string, t: number): string {
+ return `#${new Color(a).lerp(new Color(b), t).getHexString()}`;
+}
+
+/** Same hue, saturation scaled by `f` (0 = grey). */
+export function desaturate(c: string, f: number): string {
+ const col = new Color(c);
+ const hsl = { h: 0, s: 0, l: 0 };
+ col.getHSL(hsl);
+ col.setHSL(hsl.h, hsl.s * f, hsl.l);
+ return `#${col.getHexString()}`;
+}
+
+/** Build the scene palette from tokens. Pure: the theme follows the canvas token's lightness. */
+export function buildPalette(t: SceneTokens): ScenePalette {
+ const light = luminance(t.obsidian) > 0.5;
+ if (light) {
+ return {
+ theme: 'light',
+ tokens: t,
+ slabTop: mixHex(t.obsidian, t.slateHi, 0.35),
+ slabSide: mixHex(t.slateHi, t.muted, 0.35),
+ slabEdge: mixHex(t.slateHi, t.muted, 0.2),
+ corridor: mixHex(t.graphite, t.slate, 0.35),
+ room: t.graphite,
+ wall: t.slateHi,
+ wallCap: mixHex(t.graphite, t.slate, 0.2),
+ curb: t.slate,
+ inlay: mixHex(t.obsidian, t.slateHi, 0.9),
+ metal: mixHex(t.slateHi, t.muted, 0.28),
+ metalDark: mixHex(t.slateHi, t.muted, 0.55),
+ metalLight: mixHex(t.graphite, t.slate, 0.6),
+ screenOff: mixHex(t.ice, t.muted, 0.25),
+ robotShell: mixHex(t.slate, t.slateHi, 0.5),
+ robotJoint: mixHex(t.slateHi, t.muted, 0.65),
+ visor: t.ice,
+ active: t.lime,
+ success: t.success,
+ warning: t.warning,
+ failure: t.failure,
+ muted: t.muted,
+ ice: t.ice,
+ identity: t.identity,
+ identityNeutral: t.identityNeutral,
+ // Tuned so top faces render close to their token colour and sides fall off (no tone mapping).
+ lights: {
+ ambient: 0.9,
+ hemiSky: '#ffffff',
+ hemiGround: mixHex(t.slateHi, t.muted, 0.5),
+ hemi: 0.8,
+ key: 1.6,
+ keyColor: '#ffffff',
+ fill: 0.3,
+ fillColor: '#eef1f6',
+ env: 0.25,
+ },
+ glow: false,
+ };
+ }
+ return {
+ theme: 'dark',
+ tokens: t,
+ slabTop: mixHex(t.obsidian, t.graphite, 0.3),
+ slabSide: mixHex(t.worldBg, '#000000', 0.25),
+ slabEdge: mixHex(t.slateHi, t.ice, 0.12),
+ corridor: t.graphite,
+ room: mixHex(t.graphite, t.slate, 0.3),
+ wall: t.slate,
+ wallCap: mixHex(t.slateHi, t.ice, 0.1),
+ curb: mixHex(t.slate, t.graphite, 0.3),
+ inlay: mixHex(t.graphite, t.slateHi, 0.75),
+ metal: t.slateHi,
+ metalDark: mixHex(t.slate, t.obsidian, 0.35),
+ metalLight: mixHex(t.slateHi, t.muted, 0.35),
+ screenOff: mixHex(t.obsidian, t.slate, 0.4),
+ robotShell: mixHex(t.ice, t.muted, 0.08),
+ robotJoint: mixHex(t.muted, t.slate, 0.35),
+ visor: t.obsidian,
+ active: t.lime,
+ success: t.success,
+ warning: t.warning,
+ failure: t.failure,
+ muted: t.muted,
+ ice: t.ice,
+ identity: t.identity,
+ identityNeutral: t.identityNeutral,
+ lights: {
+ ambient: 0.75,
+ hemiSky: '#dfe5ee',
+ hemiGround: t.obsidian,
+ hemi: 0.85,
+ key: 2.4,
+ keyColor: '#fff8ef',
+ fill: 0.7,
+ fillColor: '#c9d3e3',
+ env: 0.45,
+ },
+ glow: true,
+ };
+}
+
+/** Room floor colour: the zone's colour, desaturated, as a quiet tint of the floor. */
+export function zoneFloor(p: ScenePalette, zoneColor: string): string {
+ return mixHex(p.room, desaturate(zoneColor, 0.35), p.theme === 'light' ? 0.16 : 0.11);
+}
+
+/** Wall tops: the wall colour with a trace of the room's tint. */
+export function zoneCap(p: ScenePalette, zoneColor: string): string {
+ return mixHex(p.wallCap, desaturate(zoneColor, 0.4), p.theme === 'light' ? 0.3 : 0.14);
+}
+
+/** Small accents in a zone's colour (wall caps, pad outlines, signs): desaturated, never emissive. */
+export function zoneAccent(p: ScenePalette, zoneColor: string): string {
+ return mixHex(p.wallCap, desaturate(zoneColor, 0.45), p.theme === 'light' ? 0.5 : 0.3);
+}
+
+/**
+ * An agent's identity colour in the scene: the same --rl-id-N token the
+ * timeline, roster and Network view use (state/identity.ts decides N).
+ */
+export function identityHex(p: ScenePalette, exec: ExecState, agentId: string): string {
+ const i = identityIndex(exec, agentId);
+ return i === null ? p.identityNeutral : (p.identity[i - 1] ?? p.identityNeutral);
+}
+
+/**
+ * A colour for a state-carrying emissive element. In the dark theme it is
+ * pushed above 1.0 so it glows (and blooms on high quality); in the light
+ * theme it stays a flat colour, because glow does not read on a light canvas.
+ */
+export function stateGlow(p: ScenePalette, c: string, intensity = 1.6): Color {
+ return new Color(c).multiplyScalar(p.glow ? intensity : 1);
+}
+
+/** Mix two colours (t = 0 → a, t = 1 → b). */
+export function mix(a: string, b: string, t: number): Color {
+ return new Color(a).lerp(new Color(b), t);
+}
diff --git a/apps/studio/src/scene/scene.module.css b/apps/studio/src/scene/scene.module.css
new file mode 100644
index 0000000..7988025
--- /dev/null
+++ b/apps/studio/src/scene/scene.module.css
@@ -0,0 +1,329 @@
+/* The 3D world's DOM layer: labels and floating controls. Colours come from tokens.css only. */
+
+.wrap {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ /* Keep label z-indexes inside the scene. */
+ isolation: isolate;
+ background:
+ radial-gradient(ellipse 72% 62% at 50% 48%, color-mix(in srgb, var(--rl-world-bg) 86%, var(--rl-text) 4%) 0%, var(--rl-world-bg) 70%);
+}
+
+.labels {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ font-family: var(--rl-font-sans);
+}
+
+.label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ visibility: hidden;
+ will-change: transform;
+}
+
+/* Only the form the projector chose is displayed. */
+.label[data-form='full'] [data-part]:not([data-part='full']),
+.label[data-form='compact'] [data-part]:not([data-part='compact']),
+.label[data-form='icon'] [data-part]:not([data-part='icon']) {
+ display: none;
+}
+
+.leader {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 1px;
+ transform-origin: 0 50%;
+ background: color-mix(in srgb, var(--rl-text-muted) 70%, transparent);
+ visibility: hidden;
+ pointer-events: none;
+}
+
+/* Hidden twins used to measure every label form. */
+.measure {
+ position: absolute;
+ left: 0;
+ top: 0;
+ visibility: hidden;
+ pointer-events: none;
+ contain: layout;
+}
+.measureItem [data-part] {
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.fallback {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: var(--rl-space-6);
+ color: var(--rl-text-muted);
+ font: var(--rl-text-md) / 1.5 var(--rl-font-sans);
+ text-align: center;
+}
+
+/* ── Label pills ─────────────────────────────────────────────────── */
+
+.hit {
+ display: block;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ pointer-events: auto;
+ border-radius: var(--rl-radius-control);
+}
+.hit:focus-visible {
+ outline: none;
+}
+.hit:focus-visible .pill {
+ box-shadow: var(--rl-focus-ring);
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+ user-select: none;
+ border-radius: var(--rl-radius-control);
+ /* Opaque-ish surface (blur is reserved for floating controls). */
+ background: color-mix(in srgb, var(--rl-surface) 94%, transparent);
+ box-shadow: 0 0 0 1px var(--rl-line-strong), 0 4px 14px rgba(0, 0, 0, 0.22);
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ line-height: 1.25;
+}
+
+.zone {
+ padding: 3px 9px 3px 7px;
+ background: color-mix(in srgb, var(--rl-surface) 78%, transparent);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ box-shadow: 0 0 0 1px var(--rl-line);
+}
+.zoneMark {
+ width: 6px;
+ height: 6px;
+ border-radius: 2px;
+ background: var(--zone);
+}
+
+.station {
+ padding: 3px 8px;
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ color: var(--rl-text);
+}
+
+.incident {
+ padding: 3px 8px 3px 6px;
+ max-width: 300px;
+ color: var(--rl-failure);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--rl-failure) 55%, transparent), 0 4px 14px rgba(0, 0, 0, 0.22);
+}
+.hit:not([data-open]) .incident {
+ color: var(--rl-text-muted);
+ box-shadow: 0 0 0 1px var(--rl-line-strong);
+}
+.hit[data-selected] .incident {
+ box-shadow: 0 0 0 1.5px var(--rl-failure), 0 4px 14px rgba(0, 0, 0, 0.28);
+}
+
+/* Agent badges: identity dot + name; state as icon (+ text in the full form). */
+.idDot {
+ width: 8px;
+ height: 8px;
+ flex: none;
+ border-radius: 50%;
+ background: var(--agent);
+}
+
+.badgeCompact {
+ padding: 3px 8px 3px 7px;
+ gap: 6px;
+}
+.badgeNameText {
+ font-weight: 600;
+}
+.badgeTone {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--tone);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+.badgeIcon {
+ padding: 4px 6px;
+ gap: 4px;
+}
+
+.badgeFull {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 5px 10px 6px 8px;
+ border-radius: var(--rl-radius-card);
+}
+.badgeName {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: var(--rl-text-sm);
+}
+.badgeState {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--tone);
+ font-size: var(--rl-text-xs);
+}
+.badgeUsage {
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+
+.hit[data-selected] .pill {
+ box-shadow: 0 0 0 1.5px var(--rl-text), 0 6px 18px rgba(0, 0, 0, 0.3);
+}
+.hit[data-dim] .pill {
+ opacity: 0.72;
+}
+
+.icon {
+ width: 12px;
+ height: 12px;
+ flex: none;
+}
+
+/* ── Floating controls (glass: reserved for controls over the world) ── */
+
+.rail {
+ position: absolute;
+ right: var(--rl-space-3);
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 3;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 3px;
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-glass);
+ backdrop-filter: blur(12px) saturate(1.2);
+ box-shadow: 0 0 0 1px var(--rl-glass-line);
+}
+.railButton {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border: 0;
+ border-radius: var(--rl-radius-sm);
+ background: none;
+ color: var(--rl-text-muted);
+ cursor: pointer;
+ transition: background var(--rl-dur-instant) var(--rl-ease-out), color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.railButton:hover:not(:disabled) {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+.railButton:focus-visible {
+ outline: none;
+ box-shadow: var(--rl-focus-ring);
+}
+.railButton:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.railButton[aria-pressed='true'] {
+ color: var(--rl-text);
+ background: var(--rl-surface-2);
+}
+.railIcon {
+ width: 16px;
+ height: 16px;
+}
+.railCount {
+ position: absolute;
+ top: 1px;
+ right: 0;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--rl-failure);
+ color: var(--rl-canvas);
+ font: 600 9px / 14px var(--rl-font-sans);
+ text-align: center;
+}
+
+.corner {
+ /* Under the stage's camera toolbar (the roster owns the bottom edge). */
+ position: absolute;
+ right: var(--rl-space-3);
+ top: 48px;
+ z-index: 3;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: var(--rl-space-1);
+ pointer-events: none;
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 10px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-glass);
+ backdrop-filter: blur(12px) saturate(1.2);
+ box-shadow: 0 0 0 1px var(--rl-glass-line);
+ color: var(--rl-text-muted);
+ font: 500 var(--rl-text-xs) / 1 var(--rl-font-sans);
+ white-space: nowrap;
+ pointer-events: auto;
+}
+.chipStrong {
+ color: var(--rl-text);
+ font-weight: 600;
+}
+.chipSep {
+ width: 1px;
+ height: 12px;
+ background: var(--rl-line-strong);
+}
+.liveDot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--rl-accent-text);
+}
+.chip[data-live='false'] .liveDot {
+ background: var(--rl-text-faint);
+}
diff --git a/apps/studio/src/scene/textures.ts b/apps/studio/src/scene/textures.ts
new file mode 100644
index 0000000..4018a8e
--- /dev/null
+++ b/apps/studio/src/scene/textures.ts
@@ -0,0 +1,105 @@
+import { BufferAttribute, BufferGeometry, CanvasTexture, Vector3 } from 'three';
+
+/** Procedural textures and geometry (no external assets). Created lazily, shared, never disposed. */
+
+let blob: CanvasTexture | null = null;
+/** Soft radial shadow used under characters and machines instead of shadow maps. */
+export function blobShadowTexture(): CanvasTexture {
+ if (blob) return blob;
+ const c = document.createElement('canvas');
+ c.width = c.height = 64;
+ const g = c.getContext('2d')!;
+ const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
+ grad.addColorStop(0, 'rgba(0,0,0,0.85)');
+ grad.addColorStop(0.55, 'rgba(0,0,0,0.45)');
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, 64, 64);
+ blob = new CanvasTexture(c);
+ return blob;
+}
+
+/**
+ * A flat ribbon along a polyline (axis-aligned segments plus vertical risers),
+ * with u = distance along the line × uPerUnit. `extend` overlaps horizontal
+ * segments by half the width so opaque ribbons have filled corners.
+ */
+export function ribbonGeometry(
+ points: readonly (readonly [number, number, number])[],
+ width: number,
+ opts: { uPerUnit?: number; extend?: boolean } = {},
+): BufferGeometry {
+ const uPerUnit = opts.uPerUnit ?? 1;
+ const pos: number[] = [];
+ const uv: number[] = [];
+ const idx: number[] = [];
+ const half = width / 2;
+ let perp = new Vector3(1, 0, 0);
+ let cum = 0;
+ // Initial perpendicular from the first horizontal segment.
+ for (let i = 0; i + 1 < points.length; i++) {
+ const d = new Vector3(points[i + 1]![0] - points[i]![0], 0, points[i + 1]![2] - points[i]![2]);
+ if (d.lengthSq() > 1e-9) {
+ d.normalize();
+ perp = new Vector3(-d.z, 0, d.x);
+ break;
+ }
+ }
+ for (let i = 0; i + 1 < points.length; i++) {
+ const a = new Vector3(...points[i]!);
+ const b = new Vector3(...points[i + 1]!);
+ const d = b.clone().sub(a);
+ const len = d.length();
+ if (len < 1e-9) continue;
+ const horizontal = Math.abs(d.y) < 1e-9;
+ const dir = d.clone().divideScalar(len);
+ if (horizontal) perp = new Vector3(-dir.z, 0, dir.x);
+ const ext = opts.extend && horizontal ? half : 0;
+ const a2 = a.clone().addScaledVector(dir, -ext);
+ const b2 = b.clone().addScaledVector(dir, ext);
+ const base = pos.length / 3;
+ for (const p of [a2.clone().addScaledVector(perp, half), a2.clone().addScaledVector(perp, -half), b2.clone().addScaledVector(perp, half), b2.clone().addScaledVector(perp, -half)]) {
+ pos.push(p.x, p.y, p.z);
+ }
+ const u0 = cum * uPerUnit;
+ const u1 = (cum + len) * uPerUnit;
+ uv.push(u0, 1, u0, 0, u1, 1, u1, 0);
+ idx.push(base, base + 1, base + 2, base + 1, base + 3, base + 2);
+ cum += len;
+ }
+ const g = new BufferGeometry();
+ g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
+ g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
+ g.setIndex(idx);
+ g.computeBoundingSphere();
+ return g;
+}
+
+/** Total length of a polyline. */
+export function polylineLength(points: readonly (readonly [number, number, number])[]): number {
+ let len = 0;
+ for (let i = 0; i + 1 < points.length; i++) {
+ const a = points[i]!;
+ const b = points[i + 1]!;
+ len += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
+ }
+ return len;
+}
+
+/** Point at distance `s` along a polyline (clamped), written into `out`. */
+export function pointAlong(points: readonly (readonly [number, number, number])[], s: number, out: Vector3): Vector3 {
+ if (points.length === 0) return out.set(0, 0, 0);
+ let left = Math.max(0, s);
+ for (let i = 0; i + 1 < points.length; i++) {
+ const a = points[i]!;
+ const b = points[i + 1]!;
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
+ if (left <= len && len > 0) {
+ const t = left / len;
+ return out.set(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
+ }
+ left -= len;
+ }
+ const last = points[points.length - 1]!;
+ return out.set(last[0], last[1], last[2]);
+}
diff --git a/apps/studio/src/scene/types.ts b/apps/studio/src/scene/types.ts
new file mode 100644
index 0000000..4856e38
--- /dev/null
+++ b/apps/studio/src/scene/types.ts
@@ -0,0 +1,61 @@
+import type { AnyEvent } from '@runloop-world/events';
+import type { RunState } from '@runloop-world/replay';
+import type { CompiledWorld } from '@runloop-world/world-engine';
+
+/** What the 3D scene needs each frame. Read it inside useFrame; never put it in React state. */
+export interface SceneFrame {
+ /** Layer 1 + 2 state at the playhead (live: latest; replay: stateAt(seqAtTime(ms))). */
+ state: RunState;
+ /** Fractional world tick at the playhead (msToTick(playheadMs, tickHz)). */
+ tick: number;
+ /** Playhead in ms since run start. */
+ playheadMs: number;
+ /** True while the playhead is advancing (live, or replay playing). */
+ advancing: boolean;
+}
+
+export type CameraMode = 'iso' | 'top';
+export type Quality = 'high' | 'low';
+
+/**
+ * Contract between the Studio shell (owns data + UI) and the scene
+ * (owns rendering). The shell renders ` ` via
+ * next/dynamic with ssr:false; `scene/WorldCanvas.tsx` default-exports it.
+ */
+export interface SceneProps {
+ world: CompiledWorld;
+ /** Called every rendered frame. Must be cheap (memoized by the shell). */
+ getFrame: () => SceneFrame;
+ /**
+ * Events that newly became visible because the playhead moved forward by a
+ * small step (live arrival or replay playback). Fires effects (tool pulses,
+ * message packets). NOT called on scrubs/jumps, so effects never replay in bulk.
+ * Returns an unsubscribe function.
+ */
+ subscribeEffects: (fn: (events: readonly AnyEvent[]) => void) => () => void;
+ selectedAgentId: string | null;
+ selectedStationId: string | null;
+ onSelectAgent: (agentId: string | null) => void;
+ onSelectStation: (stationId: string | null) => void;
+ cameraMode: CameraMode;
+ /** Keep the camera centred on the selected character. */
+ follow: boolean;
+ quality: Quality;
+ reducedMotion: boolean;
+ /** Set data-world-ready="true" on the canvas wrapper once the first frame rendered. */
+ onReady?: () => void;
+ /** Selected event (its incident marker is highlighted). */
+ selectedSeq?: number | null;
+ /** Select an event (incident markers, "next error"); `seek` also moves the playhead there. */
+ onSelectEvent?: (seq: number, opts: { seek: boolean }) => void;
+ /** What the Studio is showing (the header's connection state), repeated on the provenance chip. */
+ status?: SceneStatus;
+}
+
+/** The connection state as the world overlay shows it (same words as the header). */
+export interface SceneStatus {
+ text: string;
+ /** Events are arriving now (the dot pulses). */
+ live: boolean;
+ detail: string;
+}
diff --git a/apps/studio/src/state/commands.test.ts b/apps/studio/src/state/commands.test.ts
new file mode 100644
index 0000000..4d20684
--- /dev/null
+++ b/apps/studio/src/state/commands.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { rankCommands, type Command } from './commands';
+
+const cmd = (id: string, label: string, group = 'Go to', keywords?: string): Command => ({ id, group, label, run: () => {}, ...(keywords ? { keywords } : {}) });
+const all = [
+ cmd('overview', 'Overview'),
+ cmd('world', 'Live World', 'Go to', '3d agents'),
+ cmd('traces', 'Traces'),
+ cmd('run3', 'Run #3 · Build a task store', 'Runs'),
+ cmd('theme', 'Theme: dark', 'Preferences', 'appearance'),
+];
+const ids = (q: string) => rankCommands(all, q).map((c) => c.id);
+
+describe('command ranking', () => {
+ it('keeps everything for an empty query', () => {
+ expect(ids('')).toEqual(all.map((c) => c.id));
+ });
+ it('requires every term and searches keywords and groups', () => {
+ expect(ids('task store')).toEqual(['run3']);
+ expect(ids('3d')).toEqual(['world']);
+ expect(ids('preferences')).toEqual(['theme']);
+ expect(ids('nothing here')).toEqual([]);
+ });
+ it('ranks label prefixes, then word starts, then substrings', () => {
+ expect(ids('t')).toEqual(['traces', 'theme', 'run3', 'overview', 'world'].filter((id) => ids('t').includes(id)));
+ expect(ids('t')[0]).toBe('traces');
+ expect(ids('#3')).toEqual(['run3']);
+ });
+});
diff --git a/apps/studio/src/state/commands.ts b/apps/studio/src/state/commands.ts
new file mode 100644
index 0000000..d1daf56
--- /dev/null
+++ b/apps/studio/src/state/commands.ts
@@ -0,0 +1,38 @@
+/** A command palette entry. Commands are built in components (they need the router and the store). */
+export interface Command {
+ id: string;
+ group: string;
+ label: string;
+ /** Extra words that should find this command. */
+ keywords?: string;
+ /** Short right-aligned note (e.g. "current", a status). */
+ hint?: string;
+ /** Keys shown as a shortcut hint, e.g. ['⌘', 'K']. */
+ shortcut?: readonly string[];
+ disabled?: boolean;
+ run: () => void;
+}
+
+/**
+ * Commands matching `query`: every term must appear in the label, group or
+ * keywords. Label-prefix matches rank first, then word-start matches, then the
+ * rest, keeping the original order within a rank. An empty query keeps all.
+ */
+export function rankCommands(commands: readonly Command[], query: string): Command[] {
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
+ if (terms.length === 0) return [...commands];
+ const scored: { c: Command; score: number; i: number }[] = [];
+ commands.forEach((c, i) => {
+ const label = c.label.toLowerCase();
+ const hay = `${label} ${c.group.toLowerCase()} ${(c.keywords ?? '').toLowerCase()}`;
+ if (!terms.every((t) => hay.includes(t))) return;
+ const first = terms[0]!;
+ const score = label.startsWith(first) ? 0 : new RegExp(`(^|[\\s·#/-])${escapeRe(first)}`).test(label) ? 1 : 2;
+ scored.push({ c, score, i });
+ });
+ return scored.sort((a, b) => a.score - b.score || a.i - b.i).map((x) => x.c);
+}
+
+function escapeRe(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/apps/studio/src/state/connect.ts b/apps/studio/src/state/connect.ts
new file mode 100644
index 0000000..0c7b838
--- /dev/null
+++ b/apps/studio/src/state/connect.ts
@@ -0,0 +1,125 @@
+import { SSE_END_EVENT, type StreamEndData } from '@runloop-world/events';
+import { getAllEvents, getEventsPage, getRun, streamUrl } from '../lib/api';
+import type { StudioStore } from './store';
+
+const GAP_CHECK_MS = 1000;
+const REOPEN_MS = 3000;
+/** SSE frames are applied in small batches so a burst re-renders once. */
+const FLUSH_MS = 30;
+
+/**
+ * Load a run and keep it up to date: run detail (with the world), the event
+ * backlog page by page, then an SSE stream from the last sequence received.
+ * The browser resumes a dropped stream with Last-Event-ID; if it gives up,
+ * we reopen from the last contiguous sequence. Gaps that stay open for ~2 s
+ * are backfilled from GET /events. Returns a disconnect function; safe to
+ * call twice (React StrictMode mounts effects twice).
+ */
+export function connectRun(store: StudioStore, runId: string): () => void {
+ let closed = false;
+ let es: EventSource | null = null;
+ let reopenTimer: ReturnType | null = null;
+ let flushTimer: ReturnType | null = null;
+ let queue: unknown[] = [];
+ let backfilling = false;
+
+ const state = () => store.getState();
+ const current = () => !closed && state().runId === runId;
+
+ const flush = () => {
+ flushTimer = null;
+ const batch = queue;
+ queue = [];
+ if (current() && batch.length) state().ingest(batch, 'live');
+ };
+
+ const openStream = () => {
+ if (!current()) return;
+ const source = new EventSource(streamUrl(runId, state().lastSequence));
+ es = source;
+ source.onopen = () => {
+ if (current()) state().setConn('streaming');
+ };
+ source.onmessage = (m: MessageEvent) => {
+ queue.push(parseJson(m.data));
+ flushTimer ??= setTimeout(flush, FLUSH_MS);
+ };
+ source.addEventListener(SSE_END_EVENT, (m) => {
+ // Without close() the browser would reconnect forever.
+ source.close();
+ if (es === source) es = null;
+ flush();
+ if (!current()) return;
+ const end = parseJson((m as MessageEvent).data) as Partial | null;
+ if (end?.status) state().markEnded(end.status, end.lastSequence ?? 0);
+ state().setConn('complete');
+ });
+ source.onerror = () => {
+ if (!current()) return;
+ if (source.readyState === EventSource.CLOSED) {
+ // The browser gave up (HTTP error, CORS, runtime gone): retry ourselves.
+ if (es === source) es = null;
+ state().setConn('error');
+ reopenTimer = setTimeout(() => {
+ reopenTimer = null;
+ if (!state().ended) openStream();
+ }, REOPEN_MS);
+ } else {
+ state().setConn('reconnecting');
+ }
+ };
+ };
+
+ const checkGap = async () => {
+ if (!current() || backfilling) return;
+ const gap = state().gapToBackfill();
+ if (!gap) return;
+ backfilling = true;
+ try {
+ const page = await getEventsPage(runId, gap.from - 1, gap.to - gap.from + 1);
+ if (current()) state().ingest(page.events, 'backlog');
+ } catch {
+ // Retried on the next check.
+ } finally {
+ backfilling = false;
+ }
+ };
+ const gapTimer = setInterval(() => void checkGap(), GAP_CHECK_MS);
+
+ void (async () => {
+ try {
+ const detail = await getRun(runId);
+ if (!current()) return;
+ state().loadRun(detail);
+ const backlog = await getAllEvents(runId, state().lastSequence);
+ if (!current()) return;
+ state().ingest(backlog, 'backlog');
+ const s = state();
+ if (detail.run.status !== 'running' && s.lastSequence >= detail.run.lastSequence) {
+ s.markEnded(detail.run.status, detail.run.lastSequence);
+ s.setConn('complete');
+ return;
+ }
+ openStream();
+ } catch (err) {
+ if (current()) state().setLoadError(err instanceof Error ? err.message : String(err));
+ }
+ })();
+
+ return () => {
+ closed = true;
+ clearInterval(gapTimer);
+ if (reopenTimer) clearTimeout(reopenTimer);
+ if (flushTimer) clearTimeout(flushTimer);
+ es?.close();
+ es = null;
+ };
+}
+
+function parseJson(text: string): unknown {
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return text;
+ }
+}
diff --git a/apps/studio/src/state/connection.test.ts b/apps/studio/src/state/connection.test.ts
new file mode 100644
index 0000000..e8f3e35
--- /dev/null
+++ b/apps/studio/src/state/connection.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { connectionState, connectionView } from './connection';
+import type { StudioState } from './store';
+
+type Input = Parameters[0];
+const base: Input = {
+ runtime: { status: 'online', version: '0.1.0', activeRunId: null, ingest: null, tracedRunIds: [] },
+ runId: 'r1',
+ conn: 'streaming',
+ cursor: { mode: 'live' },
+ ended: false,
+ run: { paused: false } as StudioState['run'],
+ loadError: null,
+};
+const state = (patch: Partial ) => connectionState({ ...base, ...patch });
+
+describe('connection state', () => {
+ it('is live while a running run streams', () => {
+ expect(state({})).toBe('live');
+ });
+ it('says disconnected first when the runtime or stream is lost', () => {
+ expect(state({ runtime: { ...base.runtime, status: 'offline' }, cursor: { mode: 'replay', tMs: 0, seq: 0, playing: true, speed: 1 } })).toBe('disconnected');
+ expect(state({ conn: 'error' })).toBe('disconnected');
+ expect(state({ loadError: 'boom' })).toBe('disconnected');
+ });
+ it('labels synthetic data whenever it is shown', () => {
+ expect(connectionState(base, { synthetic: true })).toBe('synthetic');
+ });
+ it('is connecting while the runtime is unknown or the run loads', () => {
+ expect(state({ runtime: { ...base.runtime, status: 'unknown' } })).toBe('connecting');
+ expect(state({ conn: 'loading' })).toBe('connecting');
+ expect(state({ conn: 'reconnecting' })).toBe('connecting');
+ });
+ it('separates replaying from paused and recorded', () => {
+ expect(state({ cursor: { mode: 'replay', tMs: 10, seq: 1, playing: true, speed: 1 } })).toBe('replaying');
+ expect(state({ cursor: { mode: 'replay', tMs: 10, seq: 1, playing: false, speed: 1 } })).toBe('paused');
+ expect(connectionView('paused')).toMatchObject({ icon: '⏸', tone: 'info' }); // reviewing the past is not a warning
+ expect(state({ run: { paused: true } as StudioState['run'] })).toBe('paused');
+ expect(state({ ended: true, conn: 'complete' })).toBe('recorded');
+ });
+ it('is idle with no run open, and every state has words', () => {
+ expect(state({ runId: null, conn: 'idle' })).toBe('idle');
+ for (const s of ['connecting', 'live', 'paused', 'disconnected', 'replaying', 'recorded', 'synthetic', 'idle'] as const) {
+ const v = connectionView(s);
+ expect(v.text.length).toBeGreaterThan(0);
+ expect(v.detail.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/apps/studio/src/state/connection.ts b/apps/studio/src/state/connection.ts
new file mode 100644
index 0000000..6e51256
--- /dev/null
+++ b/apps/studio/src/state/connection.ts
@@ -0,0 +1,46 @@
+import type { StudioState } from './store';
+import type { StatusView } from './view';
+
+/** What the Studio is showing right now (design-system.md: always say which). */
+export type ConnectionState = 'connecting' | 'live' | 'paused' | 'disconnected' | 'replaying' | 'recorded' | 'synthetic' | 'idle';
+
+export interface ConnectionView extends StatusView {
+ state: ConnectionState;
+ /** One sentence for tooltips and screen readers. */
+ detail: string;
+}
+
+type Input = Pick;
+
+/**
+ * Priority: losing the runtime beats everything; then synthetic data is always
+ * labelled; then loading; then where the playhead is (replay) and what the run is doing.
+ */
+export function connectionState(s: Input, opts: { synthetic?: boolean } = {}): ConnectionState {
+ if (s.runtime.status === 'offline') return 'disconnected';
+ if (s.runId && (s.conn === 'error' || s.loadError)) return 'disconnected';
+ if (opts.synthetic) return 'synthetic';
+ if (s.runtime.status === 'unknown') return 'connecting';
+ if (!s.runId) return 'idle';
+ if (s.conn === 'loading' || s.conn === 'reconnecting') return 'connecting';
+ if (s.cursor.mode === 'replay') return s.cursor.playing ? 'replaying' : 'paused';
+ if (s.run?.paused) return 'paused';
+ if (s.ended) return 'recorded';
+ return 'live';
+}
+
+const VIEWS: Record> = {
+ connecting: { icon: '…', text: 'Connecting', tone: 'muted', detail: 'Connecting to the local runtime.' },
+ live: { icon: '●', text: 'Live', tone: 'live', detail: 'Events are arriving now from the runtime.' },
+ // Neutral, not a warning: reviewing a past moment is not "attention / waiting / blocked".
+ paused: { icon: '⏸', text: 'Paused', tone: 'info', detail: 'The playhead is paused at a recorded moment; nothing is re-executed.' },
+ disconnected: { icon: '✗', text: 'Disconnected', tone: 'err', detail: 'The local runtime is not reachable. Data already recorded is safe in its database.' },
+ replaying: { icon: '▶', text: 'Replaying', tone: 'info', detail: 'Replaying persisted events; nothing is re-executed.' },
+ recorded: { icon: '■', text: 'Recorded', tone: 'muted', detail: 'This run has ended; you are viewing its recorded events.' },
+ synthetic: { icon: '◇', text: 'Synthetic', tone: 'warn', detail: 'Example or fixture data, not a real run.' },
+ idle: { icon: '●', text: 'Online', tone: 'ok', detail: 'The local runtime is online. No run is open.' },
+};
+
+export function connectionView(state: ConnectionState): ConnectionView {
+ return { state, ...VIEWS[state] };
+}
diff --git a/apps/studio/src/state/context.tsx b/apps/studio/src/state/context.tsx
new file mode 100644
index 0000000..a001f50
--- /dev/null
+++ b/apps/studio/src/state/context.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { createContext, useContext, type ReactNode } from 'react';
+import { useStore } from 'zustand';
+import type { StudioState, StudioStore } from './store';
+
+const StoreContext = createContext(null);
+
+export function StudioProvider({ store, children }: { store: StudioStore; children: ReactNode }) {
+ return {children} ;
+}
+
+export function useStudioStore(): StudioStore {
+ const store = useContext(StoreContext);
+ if (!store) throw new Error('useStudioStore must be used inside ');
+ return store;
+}
+
+/** Subscribe to one slice of the studio state (re-renders only when it changes). */
+export function useStudio(selector: (s: StudioState) => T): T {
+ return useStore(useStudioStore(), selector);
+}
diff --git a/apps/studio/src/state/deeplink.test.ts b/apps/studio/src/state/deeplink.test.ts
new file mode 100644
index 0000000..04ab11a
--- /dev/null
+++ b/apps/studio/src/state/deeplink.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { linkFromView, linkTo, parseDeepLink, ROUTES, routeForDeepLink, writeDeepLink } from './deeplink';
+
+const p = (q: string) => parseDeepLink(new URLSearchParams(q));
+
+describe('deep links', () => {
+ it('parses run, view, event, agent and the playhead', () => {
+ expect(p('run=r1&view=trace&event=36&agent=tester&at=30')).toEqual({ runId: 'r1', view: 'trace', seq: 36, agentId: 'tester', at: 30 });
+ expect(p('')).toEqual({ runId: null, view: null, seq: null, agentId: null, at: null });
+ expect(p('at=0').at).toBe(0);
+ expect(p('at=-1').at).toBeNull();
+ });
+
+ it('ignores invalid views and sequences', () => {
+ expect(p('view=movie&event=0').view).toBeNull();
+ expect(p('event=0').seq).toBeNull();
+ expect(p('event=1.5').seq).toBeNull();
+ expect(p('event=abc').seq).toBeNull();
+ });
+
+ it('routes the root link to the right page', () => {
+ expect(routeForDeepLink(p(''))).toBe(ROUTES.overview);
+ expect(routeForDeepLink(p('run=r1'))).toBe(ROUTES.world);
+ expect(routeForDeepLink(p('run=r1&view=graph'))).toBe(ROUTES.world);
+ expect(routeForDeepLink(p('run=r1&view=trace&event=4'))).toBe(ROUTES.traces);
+ });
+
+ it('writes the selection and keeps unrelated parameters', () => {
+ const params = new URLSearchParams('q=test&event=3');
+ writeDeepLink(params, { runId: 'r1', view: null, seq: 7, agentId: null, at: null });
+ expect(params.toString()).toBe('q=test&run=r1&event=7');
+ expect(linkTo(ROUTES.traces, { runId: 'r1', seq: 36, agentId: 'tester' })).toBe('/traces?run=r1&event=36&agent=tester');
+ expect(p(linkTo(ROUTES.world, { runId: 'r1', view: 'graph' }).split('?')[1]!)).toEqual({ runId: 'r1', view: 'graph', seq: null, agentId: null, at: null });
+ });
+
+ it('names what is on screen: the event, or the agent card (never its derived event), and a paused playhead', () => {
+ const live = { mode: 'live' } as const;
+ const paused = { mode: 'replay', playing: false } as const;
+ expect(linkFromView({ inspectorTab: 'agent', selectedSeq: 30, selectedAgentId: 'sa-1', cursor: live, playheadSeq: 45 })).toEqual({ seq: null, agentId: 'sa-1', at: null });
+ expect(linkFromView({ inspectorTab: 'event', selectedSeq: 9, selectedAgentId: 'main', cursor: paused, playheadSeq: 9 })).toEqual({ seq: 9, agentId: null, at: null });
+ expect(linkFromView({ inspectorTab: 'event', selectedSeq: 9, selectedAgentId: 'main', cursor: paused, playheadSeq: 20 })).toEqual({ seq: 9, agentId: null, at: 20 });
+ expect(linkFromView({ inspectorTab: 'agent', selectedSeq: 30, selectedAgentId: 'sa-1', cursor: paused, playheadSeq: 31 })).toEqual({ seq: null, agentId: 'sa-1', at: 31 });
+ });
+});
diff --git a/apps/studio/src/state/deeplink.ts b/apps/studio/src/state/deeplink.ts
new file mode 100644
index 0000000..330ccdd
--- /dev/null
+++ b/apps/studio/src/state/deeplink.ts
@@ -0,0 +1,88 @@
+/** Studio areas (static-export routes). Only areas that work appear here. */
+export const ROUTES = {
+ overview: '/overview',
+ world: '/world',
+ traces: '/traces',
+ integrations: '/integrations',
+ settings: '/settings',
+} as const;
+export type Route = (typeof ROUTES)[keyof typeof ROUTES];
+
+export type DeepLinkView = 'world' | 'graph' | 'trace';
+
+/**
+ * `?run=&view=world|graph|trace&event=&agent=&at=`
+ * (design-system.md, Navigation rules). `event` is the event the inspector
+ * shows, `agent` the agent card it shows (when no event is named), and `at`
+ * a paused playhead. Without `at` a live run stays live.
+ */
+export interface DeepLink {
+ runId: string | null;
+ view: DeepLinkView | null;
+ seq: number | null;
+ agentId: string | null;
+ at: number | null;
+}
+
+/** Query parameters the selection owns (the URL sync replaces exactly these). */
+export const SELECTION_PARAMS = ['run', 'view', 'event', 'agent', 'at'] as const;
+
+const seqParam = (params: URLSearchParams, key: string, min: number): number | null => {
+ const n = Number(params.get(key));
+ return params.has(key) && Number.isInteger(n) && n >= min ? n : null;
+};
+
+export function parseDeepLink(params: URLSearchParams): DeepLink {
+ const view = params.get('view');
+ return {
+ runId: params.get('run') || null,
+ view: view === 'world' || view === 'graph' || view === 'trace' ? view : null,
+ seq: seqParam(params, 'event', 1),
+ agentId: params.get('agent') || null,
+ at: seqParam(params, 'at', 0),
+ };
+}
+
+/** Where a link to the root lands: traces for view=trace, the world when a run is named, else the overview. */
+export function routeForDeepLink(link: DeepLink): Route {
+ if (link.view === 'trace') return ROUTES.traces;
+ if (link.view === 'world' || link.view === 'graph' || link.runId) return ROUTES.world;
+ return ROUTES.overview;
+}
+
+/** Write the selection into `params`; `view` is only written when it is not the page's default. */
+export function writeDeepLink(params: URLSearchParams, link: DeepLink): void {
+ for (const key of SELECTION_PARAMS) params.delete(key);
+ if (link.runId) params.set('run', link.runId);
+ if (link.view) params.set('view', link.view);
+ if (link.seq !== null) params.set('event', String(link.seq));
+ if (link.agentId) params.set('agent', link.agentId);
+ if (link.at !== null && link.at !== undefined) params.set('at', String(link.at));
+}
+
+/**
+ * What a link to the current view names: the event when the inspector shows
+ * an event, the agent when it shows an agent card (never a derived event), and
+ * the playhead when it is paused in a replay.
+ */
+export function linkFromView(s: {
+ inspectorTab: 'event' | 'agent' | 'run';
+ selectedSeq: number | null;
+ selectedAgentId: string | null;
+ cursor: { mode: 'live' } | { mode: 'replay'; playing: boolean };
+ playheadSeq: number;
+}): Pick {
+ const seq = s.inspectorTab === 'event' ? s.selectedSeq : null;
+ const agentId = s.inspectorTab === 'agent' ? s.selectedAgentId : null;
+ const paused = s.cursor.mode === 'replay' && !s.cursor.playing;
+ // A paused playhead on the named event needs no `at`.
+ const at = paused && s.playheadSeq !== seq ? s.playheadSeq : null;
+ return { seq, agentId, at };
+}
+
+/** A shareable URL for one event (or agent) of a run on a given page. */
+export function linkTo(route: Route, link: Partial & { runId: string }): string {
+ const params = new URLSearchParams();
+ writeDeepLink(params, { view: null, seq: null, agentId: null, at: null, ...link });
+ return `${route}?${params.toString()}`;
+}
diff --git a/apps/studio/src/state/filters.test.ts b/apps/studio/src/state/filters.test.ts
new file mode 100644
index 0000000..e1ba606
--- /dev/null
+++ b/apps/studio/src/state/filters.test.ts
@@ -0,0 +1,127 @@
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import {
+ activeFilterCount,
+ EMPTY_FILTER,
+ eventSubject,
+ facetsOf,
+ matchesFilter,
+ parseFilter,
+ rowSubject,
+ toggleFacet,
+ writeFilter,
+ type FilterSubject,
+ type TraceFilter,
+} from './filters';
+import { agentOfEvent } from './selection';
+import { buildTimelineRows, rowStatusAt } from './timeline';
+
+const events = codingMissionFixture();
+const exec = foldExecution(events);
+const t0 = Date.parse(events[0]!.timestamp);
+const rows = buildTimelineRows(events, (seq) => Date.parse(events[seq - 1]!.timestamp) - t0);
+const last = events.length;
+const subjects = rows.map((r) => rowSubject(r, rowStatusAt(r, last), exec.agents[r.actorId]?.name ?? r.actorId));
+const visible = (f: Partial) => rows.filter((_, i) => matchesFilter(subjects[i]!, { ...EMPTY_FILTER, ...f }));
+
+describe('trace filter: matching', () => {
+ it('passes everything when empty', () => {
+ expect(visible({})).toHaveLength(rows.length);
+ expect(activeFilterCount(EMPTY_FILTER)).toBe(0);
+ });
+
+ it('filters by agent, tool, status and event type', () => {
+ expect(visible({ agents: ['tester'] }).every((r) => r.actorId === 'tester')).toBe(true);
+ expect(visible({ tools: ['run_command'] }).map((r) => r.callId)).toEqual(['call-6', 'call-9']);
+ // The failing test run is the only tool row with an error.
+ expect(visible({ statuses: ['error'], types: ['tool.call_started'] }).map((r) => r.callId)).toEqual(['call-6']);
+ // Tool rows merge their completion, so the completion type finds them too.
+ expect(visible({ types: ['tool.call_completed'] })).toHaveLength(9);
+ });
+
+ it('ORs values within a facet and ANDs facets together', () => {
+ const either = visible({ tools: ['run_command', 'write_file'] });
+ expect(either.map((r) => r.tool)).toEqual(['write_file', 'run_command', 'write_file', 'run_command']);
+ const both = visible({ tools: ['run_command', 'write_file'], agents: ['builder'] });
+ expect(both.map((r) => r.callId)).toEqual(['call-5', 'call-8']);
+ expect(visible({ tools: ['run_command'], agents: ['builder'] })).toEqual([]);
+ });
+
+ it('matches every text term, case-insensitively, across title, detail and agent name', () => {
+ const hits = visible({ text: 'RUN_COMMAND tester' });
+ expect(hits.map((r) => r.callId)).toEqual(expect.arrayContaining(['call-6', 'call-9']));
+ for (const r of hits) {
+ const i = rows.indexOf(r);
+ expect(subjects[i]!.text.toLowerCase()).toContain('run_command');
+ expect(subjects[i]!.text.toLowerCase()).toContain('tester');
+ }
+ expect(visible({ text: 'run_command builder' })).toEqual([]);
+ expect(visible({ text: ' ' })).toHaveLength(rows.length);
+ });
+
+ it('matches category and inferred only when the data carries them', () => {
+ const subject: FilterSubject = { text: 'Bash npm test', agentId: 'main', tool: 'Bash', category: 'test', statuses: ['error', 'inferred'], types: ['tool.call_started'] };
+ expect(matchesFilter(subject, { ...EMPTY_FILTER, categories: ['test'] })).toBe(true);
+ expect(matchesFilter(subject, { ...EMPTY_FILTER, categories: ['edit'] })).toBe(false);
+ expect(matchesFilter(subject, { ...EMPTY_FILTER, statuses: ['inferred'] })).toBe(true);
+ // Executed runs have no categories: a category filter hides their rows.
+ expect(visible({ categories: ['test'] })).toEqual([]);
+ expect(visible({ statuses: ['inferred'] })).toEqual([]);
+ });
+
+ it('sees a call as running at a playhead inside it', () => {
+ const call6 = rows.find((r) => r.callId === 'call-6')!;
+ const atStart = rowSubject(call6, rowStatusAt(call6, call6.seq), 'Tester');
+ expect(matchesFilter(atStart, { ...EMPTY_FILTER, statuses: ['running'] })).toBe(true);
+ expect(matchesFilter(atStart, { ...EMPTY_FILTER, statuses: ['error'] })).toBe(false);
+ });
+});
+
+describe('trace filter: log events', () => {
+ it('derives status, tool and agent per event', () => {
+ const failed = events[35]!; // #36 tool.call_completed (error)
+ const s = eventSubject(failed, { exec, agentId: agentOfEvent(failed, exec), agentName: 'Tester', call: { tool: 'run_command', category: null }, running: false });
+ expect(s).toMatchObject({ agentId: 'tester', tool: 'run_command', statuses: ['error'], types: ['tool.call_completed'] });
+ const start = events[33]!; // #34 tool.call_started
+ expect(eventSubject(start, { exec, agentId: 'tester', agentName: 'Tester', call: null, running: true }).statuses).toEqual(['running']);
+ const system = eventSubject(events[0]!, { exec, agentId: null, agentName: 'System', call: null, running: false });
+ expect(system.agentId).toBe('system');
+ });
+
+ it('marks inferred events from their provenance', () => {
+ const e = { ...events[12]!, provenance: 'inferred' } as (typeof events)[number];
+ expect(eventSubject(e, { exec, agentId: 'researcher', agentName: 'Researcher', call: null, running: false }).statuses).toContain('inferred');
+ });
+});
+
+describe('trace filter: URL and facets', () => {
+ it('round-trips through query parameters and keeps other parameters', () => {
+ const f: TraceFilter = { text: 'node --test', agents: ['tester', 'builder'], tools: ['run_command'], categories: [], statuses: ['error', 'running'], types: ['tool.call_completed'] };
+ const params = new URLSearchParams('run=abc&event=12');
+ writeFilter(params, f);
+ expect(params.get('run')).toBe('abc');
+ expect(params.getAll('actor')).toEqual(['tester', 'builder']);
+ expect(parseFilter(params)).toEqual(f);
+ writeFilter(params, EMPTY_FILTER);
+ expect(params.toString()).toBe('run=abc&event=12');
+ });
+
+ it('drops unknown statuses and duplicates', () => {
+ expect(parseFilter(new URLSearchParams('status=ok&status=bogus&status=ok&tool=a&tool=a'))).toMatchObject({ statuses: ['ok'], tools: ['a'] });
+ });
+
+ it('toggles facet values and counts active filters', () => {
+ const on = toggleFacet(EMPTY_FILTER, 'tools', 'read_file');
+ expect(on.tools).toEqual(['read_file']);
+ expect(toggleFacet(on, 'tools', 'read_file').tools).toEqual([]);
+ expect(activeFilterCount({ ...on, text: 'x', statuses: ['ok'] })).toBe(3);
+ });
+
+ it('offers only values present in the data', () => {
+ const facets = facetsOf(subjects);
+ expect(facets.tools).toEqual(['list_files', 'read_file', 'run_command', 'write_file']);
+ expect(facets.categories).toEqual([]);
+ expect(facets.agents).toEqual(expect.arrayContaining(['researcher', 'builder', 'tester', 'system']));
+ });
+});
diff --git a/apps/studio/src/state/filters.ts b/apps/studio/src/state/filters.ts
new file mode 100644
index 0000000..a3ee72a
--- /dev/null
+++ b/apps/studio/src/state/filters.ts
@@ -0,0 +1,182 @@
+import { formatEvent, type ExecState } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent } from '@runloop-world/events';
+import { eventProvenance } from './selection';
+import type { RowStatus, TimelineRow } from './timeline';
+
+export const STATUS_FILTERS = ['ok', 'error', 'running', 'inferred'] as const;
+export type StatusFilter = (typeof STATUS_FILTERS)[number];
+
+/** Search and filter for the timeline and logs. Facets OR within themselves and AND with each other. */
+export interface TraceFilter {
+ /** Free text; every whitespace-separated term must appear. */
+ text: string;
+ /** Agent ids ('system' for events with no agent). */
+ agents: string[];
+ tools: string[];
+ /** Tool categories (traced runs only). */
+ categories: string[];
+ statuses: StatusFilter[];
+ /** Event types. */
+ types: string[];
+}
+
+export const EMPTY_FILTER: TraceFilter = { text: '', agents: [], tools: [], categories: [], statuses: [], types: [] };
+
+/** What a timeline row or a log entry exposes to the filter. */
+export interface FilterSubject {
+ text: string;
+ agentId: string;
+ tool: string | null;
+ category: string | null;
+ statuses: readonly StatusFilter[];
+ types: readonly string[];
+}
+
+export function matchesFilter(s: FilterSubject, f: TraceFilter): boolean {
+ if (f.agents.length > 0 && !f.agents.includes(s.agentId)) return false;
+ if (f.tools.length > 0 && (s.tool === null || !f.tools.includes(s.tool))) return false;
+ if (f.categories.length > 0 && (s.category === null || !f.categories.includes(s.category))) return false;
+ if (f.statuses.length > 0 && !f.statuses.some((x) => s.statuses.includes(x))) return false;
+ if (f.types.length > 0 && !s.types.some((t) => f.types.includes(t))) return false;
+ const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean);
+ if (terms.length > 0) {
+ const hay = s.text.toLowerCase();
+ if (!terms.every((t) => hay.includes(t))) return false;
+ }
+ return true;
+}
+
+/** Number of active facets (the text query counts as one). */
+export function activeFilterCount(f: TraceFilter): number {
+ return (
+ (f.text.trim() ? 1 : 0) + f.agents.length + f.tools.length + f.categories.length + f.statuses.length + f.types.length
+ );
+}
+
+// ── URL ───────────────────────────────────────────────────────────
+
+const LIST_PARAMS = { agents: 'actor', tools: 'tool', categories: 'cat', statuses: 'status', types: 'type' } as const;
+const TEXT_PARAM = 'q';
+/** Query parameters the filter owns (the URL sync replaces exactly these). */
+export const FILTER_PARAMS: readonly string[] = [TEXT_PARAM, ...Object.values(LIST_PARAMS)];
+
+const uniq = (xs: string[]) => [...new Set(xs.map((x) => x.trim()).filter(Boolean))];
+
+export function parseFilter(params: URLSearchParams): TraceFilter {
+ const statuses = uniq(params.getAll(LIST_PARAMS.statuses)).filter((s): s is StatusFilter =>
+ (STATUS_FILTERS as readonly string[]).includes(s),
+ );
+ return {
+ text: params.get(TEXT_PARAM) ?? '',
+ agents: uniq(params.getAll(LIST_PARAMS.agents)),
+ tools: uniq(params.getAll(LIST_PARAMS.tools)),
+ categories: uniq(params.getAll(LIST_PARAMS.categories)),
+ statuses,
+ types: uniq(params.getAll(LIST_PARAMS.types)),
+ };
+}
+
+/** Write the filter into `params` (repeated keys for lists); empty facets are removed. */
+export function writeFilter(params: URLSearchParams, f: TraceFilter): void {
+ for (const key of FILTER_PARAMS) params.delete(key);
+ if (f.text.trim()) params.set(TEXT_PARAM, f.text);
+ for (const [field, key] of Object.entries(LIST_PARAMS) as [keyof typeof LIST_PARAMS, string][]) {
+ for (const v of f[field]) params.append(key, v);
+ }
+}
+
+/** Add or remove one value of a list facet. */
+export function toggleFacet(f: TraceFilter, key: K, value: TraceFilter[K][number]): TraceFilter {
+ const list = f[key] as string[];
+ const next = list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
+ return { ...f, [key]: next };
+}
+
+// ── Subjects ──────────────────────────────────────────────────────
+
+const ROW_STATUS: Partial> = { ok: 'ok', error: 'error', running: 'running' };
+
+/** A timeline row as seen at the playhead (`status` from rowStatusAt). */
+export function rowSubject(row: TimelineRow, status: RowStatus, agentName: string): FilterSubject {
+ const statuses: StatusFilter[] = [];
+ const s = ROW_STATUS[status];
+ if (s) statuses.push(s);
+ if (row.inferred) statuses.push('inferred');
+ return {
+ text: [row.title, row.detail ?? '', agentName, row.actorId, row.tool ?? '', row.category ?? '', ...row.types, ...row.failingTests].join('\n'),
+ agentId: row.actorId,
+ tool: row.tool,
+ category: row.category,
+ statuses,
+ types: row.types,
+ };
+}
+
+export interface CallInfo {
+ tool: string;
+ category: string | null;
+}
+
+/** The searchable text of an event and its severity, which never change once recorded: computed once per event. */
+const textOf = new WeakMap();
+
+function eventText(e: AnyEvent, exec: ExecState): { text: string; severity: string } {
+ const cached = textOf.get(e);
+ if (cached) return cached;
+ const f = formatEvent(e, exec);
+ let payloadText = '';
+ try {
+ payloadText = JSON.stringify(e.payload) ?? '';
+ } catch {
+ // unserialisable payloads only lose free-text matching
+ }
+ const out = {
+ text: [e.type, f.label, f.detail ?? '', e.actorId ?? '', e.taskId ?? '', `#${e.sequence}`, payloadText, isUnknownEvent(e) ? e.reason : ''].join('\n'),
+ severity: f.severity,
+ };
+ textOf.set(e, out);
+ return out;
+}
+
+/** A log entry. `call` is the event's tool call (tool.* events); `running` = that call is open at the playhead. */
+export function eventSubject(
+ e: AnyEvent,
+ ctx: { exec: ExecState; agentId: string | null; agentName: string; call: CallInfo | null; running: boolean },
+): FilterSubject {
+ const f = eventText(e, ctx.exec);
+ const statuses: StatusFilter[] = [];
+ if (e.type === 'tool.call_started' && ctx.running) statuses.push('running');
+ else if (f.severity === 'error') statuses.push('error');
+ else if (f.severity === 'ok') statuses.push('ok');
+ if (eventProvenance(e) === 'inferred') statuses.push('inferred');
+ return {
+ text: `${f.text}\n${ctx.agentName}`,
+ agentId: ctx.agentId ?? 'system',
+ tool: ctx.call?.tool ?? null,
+ category: ctx.call?.category ?? null,
+ statuses,
+ types: [e.type],
+ };
+}
+
+export interface Facets {
+ agents: string[];
+ tools: string[];
+ categories: string[];
+ types: string[];
+}
+
+/** Values present in the data (so menus only offer choices that can match). */
+export function facetsOf(subjects: readonly FilterSubject[]): Facets {
+ const agents = new Set();
+ const tools = new Set();
+ const categories = new Set();
+ const types = new Set();
+ for (const s of subjects) {
+ agents.add(s.agentId);
+ if (s.tool) tools.add(s.tool);
+ if (s.category) categories.add(s.category);
+ for (const t of s.types) types.add(t);
+ }
+ return { agents: [...agents], tools: [...tools].sort(), categories: [...categories].sort(), types: [...types].sort() };
+}
diff --git a/apps/studio/src/state/format.ts b/apps/studio/src/state/format.ts
new file mode 100644
index 0000000..e1b182f
--- /dev/null
+++ b/apps/studio/src/state/format.ts
@@ -0,0 +1,44 @@
+import type { RunSummary } from '@runloop-world/events';
+import { ROUTES } from './deeplink';
+
+/** Run number as shown in the UI: #1 is the oldest run (the list is newest first). */
+export function runNumber(runs: readonly RunSummary[], runId: string | null): number | null {
+ const i = runId ? runs.findIndex((r) => r.runId === runId) : -1;
+ return i >= 0 ? runs.length - i : null;
+}
+
+/** "17:42" today, "Oct 16 · 17:42" otherwise (local time). */
+export function formatWhen(iso: string | null, now = new Date()): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '—';
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
+ const sameDay = d.toDateString() === now.toDateString();
+ return sameDay ? time : `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} · ${time}`;
+}
+
+/** Wall-clock length of a run from its summary (null while unknown). */
+export function runDurationMs(run: RunSummary, nowMs = Date.now()): number | null {
+ const start = Date.parse(run.createdAt);
+ const end = run.endedAt ? Date.parse(run.endedAt) : run.status === 'running' ? nowMs : NaN;
+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
+}
+
+export const AREA_LABEL: Record = {
+ [ROUTES.overview]: 'Overview',
+ [ROUTES.world]: 'Live World',
+ [ROUTES.traces]: 'Traces',
+ [ROUTES.integrations]: 'Integrations',
+ [ROUTES.settings]: 'Settings',
+};
+
+/** The area a pathname belongs to ('/world/' → '/world'). */
+export function areaOf(pathname: string): string {
+ const p = pathname.replace(/\/+$/, '') || '/';
+ return Object.keys(AREA_LABEL).find((r) => p === r || p.startsWith(`${r}/`)) ?? p;
+}
+
+export function isRunPage(pathname: string): boolean {
+ const a = areaOf(pathname);
+ return a === ROUTES.world || a === ROUTES.traces;
+}
diff --git a/apps/studio/src/state/hooks.ts b/apps/studio/src/state/hooks.ts
new file mode 100644
index 0000000..ff597eb
--- /dev/null
+++ b/apps/studio/src/state/hooks.ts
@@ -0,0 +1,245 @@
+'use client';
+import { useEffect, useReducer } from 'react';
+import { getHealth, listRuns } from '../lib/api';
+import { ROUTES, linkFromView, parseDeepLink, writeDeepLink } from './deeplink';
+import { parseFilter, writeFilter } from './filters';
+import { shortcutFor } from './keyboard';
+import { effectiveReducedMotion, loadPrefs, resolveTheme, safeStorage, savePrefs, type Prefs } from './prefs';
+import { pickRunToOpen } from './runs';
+import { ONBOARDING_KEY, parseOnboarding } from './onboarding';
+import { isRunPage } from './format';
+import type { IngestStatus, StudioStore } from './store';
+
+const HEALTH_POLL_MS = 3000;
+
+/** Re-render every `ms` while `active` (for clocks that move without store updates). */
+export function useTicker(active: boolean, ms = 250): void {
+ const [, tick] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => {
+ if (!active) return;
+ const id = setInterval(tick, ms);
+ return () => clearInterval(id);
+ }, [active, ms]);
+}
+
+/** Drives replay playback from animation frames. */
+export function usePlaybackDriver(store: StudioStore): void {
+ useEffect(() => {
+ let raf = 0;
+ let last = performance.now();
+ const loop = (t: number) => {
+ const s = store.getState();
+ if (s.cursor.mode === 'replay' && s.cursor.playing) s.advance(t - last);
+ last = t;
+ raf = requestAnimationFrame(loop);
+ };
+ raf = requestAnimationFrame(loop);
+ return () => cancelAnimationFrame(raf);
+ }, [store]);
+}
+
+/** Fields newer runtimes add to /api/health, read defensively (older runtimes omit them). */
+export function healthExtras(health: unknown): { ingest: IngestStatus | null; tracedRunIds: string[] } {
+ const h = (health ?? {}) as { ingest?: unknown; tracedRunIds?: unknown };
+ const traced = Array.isArray(h.tracedRunIds) ? h.tracedRunIds.filter((x): x is string => typeof x === 'string') : [];
+ const cc = (h.ingest as { claudeCode?: { protocol?: unknown } } | null | undefined)?.claudeCode;
+ const ingest: IngestStatus | null =
+ h.ingest && typeof h.ingest === 'object' ? { claudeCode: typeof cc?.protocol === 'number' ? { protocol: cc.protocol } : null } : null;
+ return { ingest, tracedRunIds: traced };
+}
+
+/**
+ * Polls GET /api/health and the runs list. When no run is open it opens the
+ * active scripted run, else the newest open traced session, else the newest run.
+ * `apiKey` restarts polling when the runtime URL changes.
+ */
+export function useRuntimePolling(store: StudioStore, apiKey: string): void {
+ useEffect(() => {
+ let stopped = false;
+ let failures = 0;
+ let timer: ReturnType | undefined;
+ const poll = async () => {
+ const s = store.getState();
+ try {
+ const health = await getHealth(AbortSignal.timeout(2500));
+ const runs = await listRuns();
+ if (stopped) return;
+ failures = 0;
+ s.setRuntime({ status: 'online', version: health.version, activeRunId: health.activeRunId, ...healthExtras(health) });
+ s.setRuns(runs);
+ const open = store.getState();
+ const next = pickRunToOpen(health.activeRunId, open.runtime.tracedRunIds, runs);
+ if (open.runId === null && next !== null) selectRun(store, next);
+ } catch {
+ // Once online, one slow or failed poll (a busy machine) is not an outage: wait for a second.
+ failures += 1;
+ if (!stopped && (failures >= 2 || s.runtime.status !== 'online')) s.setRuntime({ status: 'offline' });
+ }
+ if (!stopped) timer = setTimeout(() => void poll(), HEALTH_POLL_MS);
+ };
+ void poll();
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ };
+ }, [store, apiKey]);
+}
+
+/** Open a run (the URL sync reflects it as ?run= on run pages). */
+export function selectRun(store: StudioStore, runId: string): void {
+ if (store.getState().runId === runId) return;
+ store.getState().openRun(runId);
+}
+
+/**
+ * Viewer preferences: load once, apply theme and motion to , follow the
+ * system settings while on "system", and persist every change.
+ */
+export function usePrefs(store: StudioStore): void {
+ useEffect(() => {
+ const p = loadPrefs();
+ const s = store.getState();
+ s.setThemePref(p.theme);
+ s.setMotionPref(p.motion);
+ s.setQuality(p.quality);
+ if (p.apiBase && p.apiBase !== s.apiBaseOverride) s.setApiBase(p.apiBase);
+
+ const light = window.matchMedia('(prefers-color-scheme: light)');
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const apply = () => {
+ const st = store.getState();
+ const root = document.documentElement;
+ root.setAttribute('data-theme', resolveTheme(st.themePref, light.matches));
+ if (st.motionPref === 'system') root.removeAttribute('data-motion');
+ else root.setAttribute('data-motion', st.motionPref);
+ const rm = effectiveReducedMotion(st.motionPref, reduce.matches);
+ if (rm !== st.reducedMotion) st.setReducedMotion(rm);
+ };
+ apply();
+ light.addEventListener('change', apply);
+ reduce.addEventListener('change', apply);
+ const unsub = store.subscribe((st, prev) => {
+ if (st.themePref !== prev.themePref || st.motionPref !== prev.motionPref) apply();
+ if (st.themePref !== prev.themePref || st.motionPref !== prev.motionPref || st.quality !== prev.quality || st.apiBaseOverride !== prev.apiBaseOverride) {
+ const next: Prefs = { theme: st.themePref, motion: st.motionPref, quality: st.quality, apiBase: st.apiBaseOverride };
+ savePrefs(next);
+ }
+ });
+ return () => {
+ light.removeEventListener('change', apply);
+ reduce.removeEventListener('change', apply);
+ unsub();
+ };
+ }, [store]);
+}
+
+/** Read ?run, ?event, ?agent, ?view and the filter once, when the Studio opens. */
+export function useDeepLinkOnce(store: StudioStore): void {
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const link = parseDeepLink(params);
+ const s = store.getState();
+ if (link.runId) selectRun(store, link.runId);
+ if (link.view === 'graph') s.setWorldView('graph');
+ s.setFilter(parseFilter(params));
+ if (link.runId && (link.seq !== null || link.agentId !== null || link.at !== null)) s.focusDeepLink({ seq: link.seq, agentId: link.agentId, at: link.at });
+ }, [store]);
+}
+
+/**
+ * Mirror the selection (run, event or agent, paused playhead, view) and the
+ * filter into the URL on the run pages, so a reload or a shared link restores
+ * what was on screen. Other params are kept.
+ */
+export function useUrlSync(store: StudioStore, pathname: string): void {
+ useEffect(() => {
+ const page = pathname.replace(/\/+$/, '');
+ if (page !== ROUTES.world && page !== ROUTES.traces) return;
+ const write = () => {
+ const s = store.getState();
+ const url = new URL(window.location.href);
+ // A deep-link target still waiting for its events stays in the URL.
+ const p = s.pendingFocus;
+ writeDeepLink(url.searchParams, {
+ runId: s.runId,
+ view: page === ROUTES.world && s.worldView === 'graph' ? 'graph' : null,
+ ...(p ? { seq: p.seq, agentId: p.agentId, at: p.at ?? null } : linkFromView(s)),
+ });
+ writeFilter(url.searchParams, s.filter);
+ if (url.href !== window.location.href) window.history.replaceState(window.history.state, '', url);
+ };
+ write();
+ return store.subscribe((s, prev) => {
+ if (
+ s.runId !== prev.runId ||
+ s.selectedSeq !== prev.selectedSeq ||
+ s.selectedAgentId !== prev.selectedAgentId ||
+ s.inspectorTab !== prev.inspectorTab ||
+ s.playheadSeq !== prev.playheadSeq ||
+ (s.cursor.mode === 'replay') !== (prev.cursor.mode === 'replay') ||
+ (s.cursor.mode === 'replay' && prev.cursor.mode === 'replay' && s.cursor.playing !== prev.cursor.playing) ||
+ s.pendingFocus !== prev.pendingFocus ||
+ s.worldView !== prev.worldView ||
+ s.filter !== prev.filter
+ ) {
+ write();
+ }
+ });
+ }, [store, pathname]);
+}
+
+/** Space, ←/→, L, [ ], F, and Escape to clear the selection. */
+export function useKeyboardShortcuts(store: StudioStore): void {
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const s = store.getState();
+ if (s.viewArtifactId) return;
+ const target = e.target instanceof Element ? e.target : null;
+ if (e.key === 'Escape' && !e.defaultPrevented && !target?.closest('input, textarea, select, [role="dialog"]')) {
+ if (s.selectedSeq !== null || s.selectedAgentId !== null || s.selectedStationId !== null) s.clearSelection();
+ return;
+ }
+ const action = shortcutFor(e, target);
+ if (!action) return;
+ e.preventDefault();
+ if (action === 'togglePlay') s.togglePlay();
+ else if (action === 'stepPrev') s.stepPrev();
+ else if (action === 'stepNext') s.stepNext();
+ else if (action === 'goLive') s.goLive();
+ else if (action === 'prevAgent') s.cycleAgent(-1);
+ else if (action === 'nextAgent') s.cycleAgent(1);
+ else s.toggleFollow();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [store]);
+}
+
+/**
+ * Getting-started progress for this viewer: load it once, then mark "opened a
+ * run", "inspected a failure" and "replayed" as they happen, and persist.
+ */
+export function useOnboardingTracker(store: StudioStore, pathname: string): void {
+ useEffect(() => {
+ store.getState().setOnboarding(parseOnboarding(safeStorage.getItem(ONBOARDING_KEY)));
+ return store.subscribe((s, prev) => {
+ if (s.onboarding !== prev.onboarding) safeStorage.setItem(ONBOARDING_KEY, JSON.stringify(s.onboarding));
+ });
+ }, [store]);
+
+ useEffect(() => {
+ const check = (s: ReturnType) => {
+ const o = s.onboarding;
+ if (!o.opened && isRunPage(pathname) && s.runId && s.lastSequence > 0) s.setOnboarding({ opened: true });
+ if (!o.replayed && s.cursor.mode === 'replay') s.setOnboarding({ replayed: true });
+ if (!o.inspectedFailure && s.selectedSeq !== null) {
+ const seq = s.selectedSeq;
+ if (s.stateAt(s.lastSequence).exec.incidents.some((i) => i.seq === seq)) s.setOnboarding({ inspectedFailure: true });
+ }
+ };
+ check(store.getState());
+ return store.subscribe((s, prev) => {
+ if (s.runId !== prev.runId || s.lastSequence !== prev.lastSequence || s.cursor !== prev.cursor || s.selectedSeq !== prev.selectedSeq) check(s);
+ });
+ }, [store, pathname]);
+}
diff --git a/apps/studio/src/state/identity.test.ts b/apps/studio/src/state/identity.test.ts
new file mode 100644
index 0000000..8c2bd8a
--- /dev/null
+++ b/apps/studio/src/state/identity.test.ts
@@ -0,0 +1,14 @@
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { identityColor, identityIndex } from './identity';
+
+describe('agent identity hues', () => {
+ const exec = foldExecution(codingMissionFixture());
+ it('gives embodied agents hues in registration order and others a neutral colour', () => {
+ expect(['researcher', 'builder', 'tester'].map((id) => identityIndex(exec, id))).toEqual([1, 2, 3]);
+ expect(identityIndex(exec, 'coordinator')).toBeNull();
+ expect(identityColor(exec, 'builder')).toBe('var(--rl-id-2)');
+ expect(identityColor(exec, 'system')).toBe('var(--rl-id-neutral)');
+ });
+});
diff --git a/apps/studio/src/state/identity.ts b/apps/studio/src/state/identity.ts
new file mode 100644
index 0000000..7641434
--- /dev/null
+++ b/apps/studio/src/state/identity.ts
@@ -0,0 +1,27 @@
+import type { ExecState } from '@runloop-world/core';
+
+/** Number of identity hues in tokens.css (--rl-id-1 … --rl-id-8). */
+export const IDENTITY_HUES = 8;
+
+/**
+ * Identity hue (1-based) of an agent: embodied agents in registration order,
+ * wrapping after 8. Non-embodied agents, 'system' and unknown ids have none.
+ * Identity never signals state; the same index is meant for the 3D robots and
+ * the agent graph so an agent looks the same everywhere.
+ */
+export function identityIndex(exec: ExecState, agentId: string): number | null {
+ if (!exec.agents[agentId]?.embodied) return null;
+ let i = 0;
+ for (const id of exec.agentOrder) {
+ if (!exec.agents[id]?.embodied) continue;
+ if (id === agentId) return (i % IDENTITY_HUES) + 1;
+ i += 1;
+ }
+ return null;
+}
+
+/** CSS colour for an agent's identity (a token reference). */
+export function identityColor(exec: ExecState, agentId: string): string {
+ const i = identityIndex(exec, agentId);
+ return i === null ? 'var(--rl-id-neutral)' : `var(--rl-id-${i})`;
+}
diff --git a/apps/studio/src/state/keyboard.test.ts b/apps/studio/src/state/keyboard.test.ts
new file mode 100644
index 0000000..bdd4ddf
--- /dev/null
+++ b/apps/studio/src/state/keyboard.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { shortcutFor } from './keyboard';
+
+const key = (k: string, mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; defaultPrevented: boolean }> = {}) => ({
+ key: k,
+ metaKey: false,
+ ctrlKey: false,
+ altKey: false,
+ defaultPrevented: false,
+ ...mods,
+});
+
+describe('keyboard shortcuts', () => {
+ it('maps Space, arrows, L, [ ], F', () => {
+ expect(shortcutFor(key(' '), null)).toBe('togglePlay');
+ expect(shortcutFor(key('ArrowLeft'), null)).toBe('stepPrev');
+ expect(shortcutFor(key('ArrowRight'), null)).toBe('stepNext');
+ expect(shortcutFor(key('l'), null)).toBe('goLive');
+ expect(shortcutFor(key('L'), null)).toBe('goLive');
+ expect(shortcutFor(key('['), null)).toBe('prevAgent');
+ expect(shortcutFor(key(']'), null)).toBe('nextAgent');
+ expect(shortcutFor(key('f'), null)).toBe('toggleFollow');
+ expect(shortcutFor(key('x'), null)).toBeNull();
+ });
+
+ it('leaves modified keys to the browser', () => {
+ expect(shortcutFor(key('l', { ctrlKey: true }), null)).toBeNull();
+ expect(shortcutFor(key('f', { metaKey: true }), null)).toBeNull();
+ expect(shortcutFor(key(' ', { defaultPrevented: true }), null)).toBeNull();
+ });
+});
diff --git a/apps/studio/src/state/keyboard.ts b/apps/studio/src/state/keyboard.ts
new file mode 100644
index 0000000..1afe6f0
--- /dev/null
+++ b/apps/studio/src/state/keyboard.ts
@@ -0,0 +1,34 @@
+export type ShortcutAction = 'togglePlay' | 'stepPrev' | 'stepNext' | 'goLive' | 'prevAgent' | 'nextAgent' | 'toggleFollow';
+
+const KEYS: Record = {
+ ' ': 'togglePlay',
+ ArrowLeft: 'stepPrev',
+ ArrowRight: 'stepNext',
+ l: 'goLive',
+ L: 'goLive',
+ '[': 'prevAgent',
+ ']': 'nextAgent',
+ f: 'toggleFollow',
+ F: 'toggleFollow',
+};
+
+/** Keys that controls handle themselves when focused (Space on a button, arrows in a slider/tab list). */
+const NATIVE: Record = {
+ ' ': 'button, a, summary, [role="tab"], input, select, textarea',
+ ArrowLeft: 'input, select, textarea, [role="tablist"]',
+ ArrowRight: 'input, select, textarea, [role="tablist"]',
+};
+
+/** Global shortcut for a key press, or null when the focused control should get it. */
+export function shortcutFor(
+ e: Pick,
+ target: Element | null,
+): ShortcutAction | null {
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return null;
+ const action = KEYS[e.key];
+ if (!action || !target) return action ?? null;
+ if (target.closest('input[type="text"], input[type="search"], textarea, select, [contenteditable="true"], [role="dialog"]')) return null;
+ const native = NATIVE[e.key];
+ if (native && target.closest(native)) return null;
+ return action;
+}
diff --git a/apps/studio/src/state/labels.test.ts b/apps/studio/src/state/labels.test.ts
new file mode 100644
index 0000000..67c3490
--- /dev/null
+++ b/apps/studio/src/state/labels.test.ts
@@ -0,0 +1,110 @@
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { categoryLabel, describeEvent, formatTokens, shortModel, usageParts, usageText } from './labels';
+import { tracedFixture } from './test-utils';
+import { buildTimelineRows } from './timeline';
+
+const events = tracedFixture();
+const exec = foldExecution(events);
+const first = (type: string, pred: (e: (typeof events)[number]) => boolean = () => true) => events.find((e) => e.type === type && pred(e))!;
+
+describe('token and model formatting', () => {
+ it('abbreviates token counts with a fixed locale', () => {
+ expect(formatTokens(830)).toBe('830');
+ expect(formatTokens(1000)).toBe('1k');
+ expect(formatTokens(1234)).toBe('1.23k');
+ expect(formatTokens(20520)).toBe('20.5k');
+ expect(formatTokens(12_300)).toBe('12.3k');
+ expect(formatTokens(1_234_567)).toBe('1.23M');
+ });
+
+ it('shortens model ids to their family and keeps unknown ids', () => {
+ expect(shortModel('claude-haiku-5-5')).toBe('haiku');
+ expect(shortModel('claude-opus-5-5')).toBe('opus');
+ expect(shortModel('gpt-x')).toBe('gpt-x');
+ });
+
+ it('words usage from the reported fields only', () => {
+ const p = { model: 'claude-haiku-5-5', inputTokens: 2, outputTokens: 193, cacheReadTokens: 19620, totalTokens: 20520 };
+ expect(usageText(p)).toBe('20.5k tokens · haiku');
+ expect(usageText({ ...p, model: null })).toBe('20.5k tokens');
+ expect(usageParts(p)).toBe('in 2 · out 193 · cache read 19,620'); // no cache write: not reported, not "0"
+ });
+
+ it('labels categories', () => {
+ expect(categoryLabel('execute')).toBe('Command');
+ expect(categoryLabel('something-new')).toBe('something-new');
+ });
+});
+
+describe('event labels', () => {
+ it('uses agent and task names instead of ids', () => {
+ expect(describeEvent(first('task.assigned'), exec).label).toBe('Assigned Summarize task-store.js to Explore #1');
+ expect(describeEvent(first('agent.message_sent'), exec).label).toBe('You → Claude');
+ expect(describeEvent(first('task.completed'), exec).label).toBe('Completed Summarize task-store.js');
+ });
+
+ it('words traced sessions, delegation, usage and inferred answers', () => {
+ expect(describeEvent(first('run.started'), exec)).toMatchObject({ label: 'Session started · demo', detail: 'Claude Code session 448e50c5' });
+ expect(describeEvent(events.at(-1)!, exec)).toMatchObject({ label: 'Session ended', detail: 'Session ended (other)', severity: 'info' });
+ expect(describeEvent(first('task.created', (e) => e.taskId === 'delegate-1'), exec).label).toBe('Delegated to Explore #1: Summarize task-store.js');
+ expect(describeEvent(first('agent.registered', (e) => (e.payload as { agentId: string }).agentId === 'sa-1'), exec)).toMatchObject({
+ label: 'Explore #1 joined',
+ detail: 'subagent (Explore)',
+ });
+ const usage = describeEvent(first('agent.usage_reported'), exec);
+ expect(usage.label).toBe('Usage: 20.5k tokens · haiku');
+ expect(usage.detail).toBe('in 2 · out 193 · cache read 19,620 · cache write 705 · reported by Claude Code (Agent tool result)');
+ expect(describeEvent(first('tool.permission_resolved'), exec)).toMatchObject({ label: 'Permission denied', detail: 'Who answered was not reported' });
+ expect(describeEvent(first('tool.call_failed'), exec).label).toBe('Bash: denied');
+ expect(describeEvent(first('run.marker'), exec).label).toBe('Compaction started (manual)');
+ });
+
+ it('leaves executed runs in the core wording apart from names', () => {
+ const demo = codingMissionFixture();
+ const demoExec = foldExecution(demo);
+ const done = demo.find((e) => e.type === 'run.completed')!;
+ expect(describeEvent(done, demoExec).label).toBe('Run succeeded');
+ const started = demo.find((e) => e.type === 'run.started')!;
+ expect(describeEvent(started, demoExec).label).toMatch(/^Run started: /);
+ });
+});
+
+describe('timeline rows for a traced run', () => {
+ const t0 = Date.parse(events[0]!.timestamp);
+ const rows = buildTimelineRows(events, (seq) => Date.parse(events[seq - 1]!.timestamp) - t0, exec);
+
+ it('folds a delegation into one row that names the subagent', () => {
+ const row = rows.find((r) => r.taskId === 'delegate-1' && r.type === 'task.created')!;
+ expect(row.title).toBe('Delegated to Explore #1: Summarize task-store.js');
+ expect(row.types).toEqual(['task.created', 'task.assigned', 'task.started']);
+ expect(row.delegateId).toBe('sa-1');
+ expect(row.actorId).toBe('main');
+ expect(rows.some((r) => r.type === 'task.assigned' || r.type === 'task.started')).toBe(false);
+ });
+
+ it('links the Agent call to the subagent it started', () => {
+ const call = rows.find((r) => r.tool === 'Agent')!;
+ expect(call.delegateId).toBe('sa-1');
+ expect(call.category).toBe('delegate');
+ expect(rows.find((r) => r.tool === 'Read')!.delegateId).toBeNull();
+ });
+
+ it('shows a turn as one row attributed to the agent that works it', () => {
+ const turn = rows.find((r) => r.taskId === 'turn-1' && r.type === 'task.created')!;
+ expect(turn).toMatchObject({ title: 'Turn 1 started', actorId: 'main', types: ['task.created', 'task.started'] });
+ });
+
+ it('marks inferred rows and words failures', () => {
+ const denied = rows.find((r) => r.callId === 'c-perm')!;
+ expect(denied).toMatchObject({ status: 'error', inferred: true });
+ expect(denied.detail).toMatch(/^Denied: /);
+ expect(rows.find((r) => r.type === 'tool.permission_resolved')!.inferred).toBe(true);
+ });
+
+ it('gives markers and usage rows their own icons', () => {
+ expect(rows.find((r) => r.type === 'run.marker')!.icon).toBe('◫');
+ expect(rows.find((r) => r.type === 'agent.usage_reported')!.icon).toBe('¤');
+ });
+});
diff --git a/apps/studio/src/state/labels.ts b/apps/studio/src/state/labels.ts
new file mode 100644
index 0000000..dd5238c
--- /dev/null
+++ b/apps/studio/src/state/labels.ts
@@ -0,0 +1,223 @@
+import { formatEvent, type ExecState, type FormattedEvent } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent, type RunMarkerKind, type ToolCategory } from '@runloop-world/events';
+import type { IconName } from '../ui/Icon';
+
+/**
+ * Studio wording for events: core's text formatter (payload fields only) with
+ * agent and task names instead of raw ids, and traced-run wording (sessions,
+ * delegation, reported usage, markers). Never adds facts the payload lacks.
+ */
+
+export const CATEGORY_LABEL: Record = {
+ read: 'Read',
+ search: 'Search',
+ web: 'Web',
+ edit: 'Edit',
+ execute: 'Command',
+ test: 'Test',
+ deploy: 'Deploy',
+ mcp: 'MCP',
+ delegate: 'Delegate',
+ plan: 'Plan',
+ other: 'Other',
+};
+
+export const CATEGORY_ICON: Record = {
+ read: 'file',
+ search: 'search',
+ web: 'globe',
+ edit: 'pencil',
+ execute: 'terminal',
+ test: 'flask',
+ deploy: 'upload',
+ mcp: 'integrations',
+ delegate: 'agent',
+ plan: 'map',
+ other: 'dot',
+};
+
+/** Label for a category string from the wire (unknown values pass through). */
+export function categoryLabel(c: string): string {
+ return (CATEGORY_LABEL as Record)[c] ?? c;
+}
+
+export const MARKER_GLYPH: Record = {
+ compaction: '◫',
+ notification: '🔔',
+ api_error: '⚠',
+ session_resumed: '↻',
+ model_switch: '⇄',
+ other: '⚑',
+};
+
+/** Human wording for tool.call_failed error types. */
+const FAILURE_TEXT: Record = {
+ denied: 'denied',
+ aborted: 'interrupted',
+ timeout: 'timed out',
+ spawn_error: 'could not start',
+ exception: 'crashed',
+ runtime_restarted: 'cut off by a runtime restart',
+ unobserved: 'result not reported',
+};
+
+export function failureText(errorType: string): string {
+ return FAILURE_TEXT[errorType] ?? `could not run (${errorType})`;
+}
+
+/** A traced run (recorded from an external agent's reports), from its folded state. */
+export function isTracedExec(exec: ExecState): boolean {
+ return exec.run.mode === 'traced' || exec.run.adapter?.kind === 'external';
+}
+
+/** Display name of an agent id ('all' and 'system' included). */
+export function nameOf(exec: ExecState | undefined, id: string): string {
+ if (id === 'all') return 'everyone';
+ if (id === 'system') return 'System';
+ return exec?.agents[id]?.name ?? id;
+}
+
+function taskTitle(exec: ExecState | undefined, taskId: string): string {
+ return exec?.tasks[taskId]?.title ?? taskId;
+}
+
+// ── Tokens and models ───────────────────────────────────────────────
+
+/** 830 → "830", 20520 → "20.5k", 1234567 → "1.23M" (fixed locale). */
+export function formatTokens(n: number): string {
+ if (n < 1000) return String(Math.round(n));
+ if (n < 1_000_000) return `${trim(n / 1000, n < 10_000 ? 2 : 1)}k`;
+ return `${trim(n / 1_000_000, 2)}M`;
+}
+
+const trim = (v: number, digits: number) => v.toFixed(digits).replace(/\.?0+$/, '');
+
+/** 20520 → "20,520". */
+export function formatInt(n: number): string {
+ return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+/** Short model name for compact cells ("claude-haiku-5-5" → "haiku"); the full id belongs in a tooltip. */
+export function shortModel(model: string): string {
+ const family = /(opus|sonnet|haiku|fable)/i.exec(model);
+ return family ? family[1]!.toLowerCase() : model;
+}
+
+export interface UsageLike {
+ model: string | null;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens?: number | undefined;
+ cacheCreationTokens?: number | undefined;
+ totalTokens?: number | undefined;
+}
+
+/** The reported total, or the sum of the reported parts when the source gave no total. */
+export function usageTotal(u: UsageLike): number {
+ return u.totalTokens ?? u.inputTokens + u.outputTokens + (u.cacheReadTokens ?? 0) + (u.cacheCreationTokens ?? 0);
+}
+
+/** "20.5k tokens · haiku". */
+export function usageText(u: UsageLike): string {
+ return `${formatTokens(usageTotal(u))} tokens${u.model ? ` · ${shortModel(u.model)}` : ''}`;
+}
+
+/** "in 2 · out 193 · cache read 19,620 · cache write 705" (only the parts that were reported). */
+export function usageParts(u: UsageLike): string {
+ const parts = [`in ${formatInt(u.inputTokens)}`, `out ${formatInt(u.outputTokens)}`];
+ if (u.cacheReadTokens !== undefined) parts.push(`cache read ${formatInt(u.cacheReadTokens)}`);
+ if (u.cacheCreationTokens !== undefined) parts.push(`cache write ${formatInt(u.cacheCreationTokens)}`);
+ return parts.join(' · ');
+}
+
+const USAGE_SOURCE: Record = {
+ 'claude-code.agent_result': 'reported by Claude Code (Agent tool result)',
+};
+
+/** Where a usage report came from, in words. */
+export function usageSourceText(source: string): string {
+ return USAGE_SOURCE[source] ?? `reported by ${source}`;
+}
+
+// ── Event labels ────────────────────────────────────────────────────
+
+/**
+ * One readable line for an event. `exec` (normally the latest state) supplies
+ * agent and task names; without it the core wording is used.
+ */
+export function describeEvent(e: AnyEvent, exec?: ExecState): FormattedEvent {
+ const f = formatEvent(e, exec);
+ if (isUnknownEvent(e) || !exec) return f;
+ const traced = isTracedExec(exec);
+ const out = (label: string, detail: string | null = f.detail, severity: FormattedEvent['severity'] = f.severity): FormattedEvent => ({
+ ...f,
+ label,
+ detail,
+ severity,
+ });
+
+ switch (e.type) {
+ case 'run.started': {
+ const src = e.payload.source;
+ if (!src) return f;
+ const part = src.segment > 1 ? ` · part ${src.segment}` : '';
+ return out(`Session started · ${src.projectLabel}`, `Claude Code session ${src.sessionId.slice(0, 8)}${part}`);
+ }
+ case 'run.completed': {
+ if (!traced) return f;
+ const o = e.payload.outcome;
+ const label = o === 'stopped' ? 'Session ended' : o === 'interrupted' ? 'Session closed (interrupted)' : `Session ${o}`;
+ return out(label, e.payload.summary ?? null, o === 'stopped' ? 'info' : f.severity);
+ }
+ case 'agent.registered': {
+ if (!traced) return f;
+ const p = e.payload;
+ if (p.role === 'user') return out(`${p.name} joined`, 'the person prompting Claude Code');
+ const what = p.role === 'main agent' ? p.role : `subagent (${p.role})`;
+ return out(`${p.name} joined`, `${what}${p.model ? ` · ${p.model}` : ''}${p.embodied ? '' : ' · no character'}`);
+ }
+ case 'agent.state_changed':
+ return out(`${nameOf(exec, e.payload.agentId)} → ${e.payload.state}`);
+ case 'agent.message_sent':
+ return out(`${nameOf(exec, e.payload.fromAgentId)} → ${nameOf(exec, e.payload.to)}`, e.payload.text);
+ case 'agent.usage_reported': {
+ const p = e.payload;
+ return out(`Usage: ${usageText(p)}`, `${usageParts(p)} · ${usageSourceText(p.source)}`);
+ }
+ case 'task.created': {
+ const p = e.payload;
+ if (p.kind === 'delegated') {
+ const owner = exec.tasks[p.taskId]?.ownerId;
+ return out(`Delegated to ${owner ? nameOf(exec, owner) : 'a subagent'}: ${p.title}`, null);
+ }
+ if (p.kind === 'turn') return out(`${p.title} started`, null);
+ return f;
+ }
+ case 'task.assigned':
+ return out(`Assigned ${taskTitle(exec, e.payload.taskId)} to ${nameOf(exec, e.payload.agentId)}`);
+ case 'task.started':
+ return out(f.label.replace(e.payload.taskId, taskTitle(exec, e.payload.taskId)));
+ case 'task.blocked':
+ return out(`Blocked: ${taskTitle(exec, e.payload.taskId)}`);
+ case 'task.completed':
+ return out(`Completed ${taskTitle(exec, e.payload.taskId)}`);
+ case 'task.failed': {
+ const p = e.payload;
+ const detail = p.errorType === 'api_error' ? `API error: ${p.reason}` : p.reason;
+ return out(`Failed ${taskTitle(exec, p.taskId)}`, detail);
+ }
+ case 'tool.call_failed': {
+ const call = exec.toolCalls.get(e.payload.callId);
+ return out(`${call?.tool ?? e.payload.callId}: ${failureText(e.payload.errorType)}`, e.payload.message);
+ }
+ case 'tool.permission_requested':
+ return out(f.label, traced ? `permission mode: ${e.payload.scope}` : f.detail);
+ case 'tool.permission_resolved': {
+ const p = e.payload;
+ if (p.by !== 'unknown') return f;
+ return out(`Permission ${p.decision}`, 'Who answered was not reported');
+ }
+ default:
+ return f;
+ }
+}
diff --git a/apps/studio/src/state/metrics.test.ts b/apps/studio/src/state/metrics.test.ts
new file mode 100644
index 0000000..ab4ac19
--- /dev/null
+++ b/apps/studio/src/state/metrics.test.ts
@@ -0,0 +1,124 @@
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture, runBuilder } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { computeRunMetrics } from './metrics';
+import { tracedFixture } from './test-utils';
+
+const metricsOf = (events: ReturnType) => computeRunMetrics(foldExecution(events), events);
+
+describe('run metrics: traced run', () => {
+ const events = tracedFixture();
+ const m = metricsOf(events);
+
+ it('measures duration from run start to run end', () => {
+ const start = Date.parse(events[0]!.timestamp);
+ const end = Date.parse(events.at(-1)!.timestamp);
+ expect(m.duration).toEqual({ ms: end - start, running: false });
+ expect(m.events).toBe(events.length);
+ });
+
+ it('counts tool calls by status, category and tool', () => {
+ expect(m.calls).toMatchObject({ total: 5, success: 3, error: 1, couldNotRun: 1, running: 0, inferred: 1, categorised: 5 });
+ expect(m.calls.byCategory).toEqual([
+ { key: 'test', count: 2, errors: 1 },
+ { key: 'delegate', count: 1, errors: 0 },
+ { key: 'deploy', count: 1, errors: 1 },
+ { key: 'read', count: 1, errors: 0 },
+ ]);
+ expect(m.calls.byTool[0]).toEqual({ key: 'Bash', count: 3, errors: 2 });
+ });
+
+ it('reports tests exactly as the runner reported them, latest run last', () => {
+ expect(m.tests).toMatchObject({ runs: 2, failingRuns: 1, latest: { tests: 12, passed: 12, failed: 0, callId: 'c-test2' } });
+ });
+
+ it('keeps usage per agent as reported and never turns "not reported" into zero', () => {
+ const byId = Object.fromEntries(m.usage.agents.map((a) => [a.agentId, a]));
+ expect(Object.keys(byId)).toEqual(['main', 'sa-1']); // the user is not an agent that spends tokens
+ expect(byId.main).toMatchObject({ usage: null, totalTokens: null, model: null, reportedBy: null });
+ expect(byId['sa-1']).toMatchObject({ totalTokens: 20520, model: 'claude-haiku-5-5', reportedBy: 'reported by Claude Code (Agent tool result)' });
+ expect(m.usage).toMatchObject({ reportedAgents: 1, reportedTotal: 20520 });
+ expect(m.cost).toBeNull();
+ });
+
+ it('counts permissions and leaves out wait times Runloop only inferred', () => {
+ expect(m.permissions).toEqual({ requested: 1, approved: 0, denied: 1, pending: 0, inferred: 1, waits: null });
+ });
+
+ it('counts failures, turns and delegations', () => {
+ expect(m.failures.total).toBe(2);
+ expect(m.failures.byKind).toEqual({ tool_error: 1, tool_failed: 1 });
+ expect(m.failures.firstSeq).toBe(events.find((e) => e.type === 'tool.call_completed' && e.payload.status === 'error')!.sequence);
+ expect(m.turns).toBe(1);
+ expect(m.delegations).toBe(1);
+ expect(m.traced).toBe(true);
+ });
+
+ it('reports a run in progress as running, measured to its last event', () => {
+ const cut = events.findIndex((e) => e.type === 'agent.usage_reported') + 1;
+ const partial = computeRunMetrics(foldExecution(events.slice(0, cut)), events.slice(0, cut));
+ expect(partial.duration).toEqual({ ms: Date.parse(events[cut - 1]!.timestamp) - Date.parse(events[0]!.timestamp), running: true });
+ expect(partial.calls.running).toBe(1); // the permission-gated call is still open
+ expect(partial.permissions.pending).toBe(1);
+ expect(partial.tests!.latest.failed).toBe(2);
+ });
+});
+
+describe('run metrics: executed demo', () => {
+ const events = codingMissionFixture();
+ const m = computeRunMetrics(foldExecution(events), events);
+
+ it('has no categories, tokens or turns: they stay unreported, not zero', () => {
+ expect(m.traced).toBe(false);
+ expect(m.calls.categorised).toBe(0);
+ expect(m.calls.byCategory).toEqual([]);
+ expect(m.calls.byTool.length).toBeGreaterThan(0);
+ expect(m.usage.reportedTotal).toBeNull();
+ expect(m.usage.agents.every((a) => a.totalTokens === null)).toBe(true);
+ expect(m.turns).toBeNull();
+ });
+
+ it('reads the failing then passing test runs from the payload metrics', () => {
+ expect(m.tests).toMatchObject({ runs: 2, failingRuns: 1, latest: { tests: 12, passed: 12, failed: 0 } });
+ });
+
+ it('records no permission requests (none happened), as zero counts and no waits', () => {
+ expect(m.permissions).toEqual({ requested: 0, approved: 0, denied: 0, pending: 0, inferred: 0, waits: null });
+ });
+});
+
+describe('run metrics: permission waits', () => {
+ it('times answers that were recorded, from request to answer', () => {
+ const b = runBuilder();
+ b.add('run.started', { title: 't', missionId: 'm', worldId: 'w', seed: 1, adapter: { id: 'a', kind: 'scripted', label: 'A' }, workspace: 'w' });
+ b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'x', model: null, tools: [], embodied: true });
+ b.add('tool.permission_requested', { permissionId: 'p1', agentId: 'a1', action: 'deploy', scope: 'prod' });
+ b.wait(2500);
+ b.add('tool.permission_resolved', { permissionId: 'p1', decision: 'approved', by: 'user' });
+ b.add('tool.permission_requested', { permissionId: 'p2', agentId: 'a1', action: 'push', scope: 'repo' });
+ b.wait(500);
+ b.add('tool.permission_resolved', { permissionId: 'p2', decision: 'denied', by: 'user' });
+ b.add('tool.permission_requested', { permissionId: 'p3', agentId: 'a1', action: 'rm', scope: 'repo' });
+ const m = computeRunMetrics(foldExecution(b.events), b.events);
+ expect(m.permissions).toEqual({ requested: 3, approved: 1, denied: 1, pending: 1, inferred: 0, waits: { count: 2, totalMs: 3000, longestMs: 2500 } });
+ });
+});
+
+describe('run metrics: empty and partial runs', () => {
+ it('has no duration before run.started and nothing reported', () => {
+ const m = computeRunMetrics(foldExecution([]), []);
+ expect(m.duration).toBeNull();
+ expect(m.tests).toBeNull();
+ expect(m.usage.reportedTotal).toBeNull();
+ expect(m.calls.total).toBe(0);
+ });
+
+ it('sums the reported parts when the source gave no total', () => {
+ const b = runBuilder();
+ b.add('run.started', { title: 't', missionId: 'm', worldId: 'w', seed: 1, adapter: { id: 'a', kind: 'scripted', label: 'A' }, workspace: 'w' });
+ b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'x', model: null, tools: [], embodied: true });
+ b.add('agent.usage_reported', { agentId: 'a1', model: null, inputTokens: 10, outputTokens: 5, source: 'test' });
+ const m = computeRunMetrics(foldExecution(b.events), b.events);
+ expect(m.usage.agents[0]).toMatchObject({ totalTokens: 15, model: null, reportedBy: 'reported by test' });
+ });
+});
diff --git a/apps/studio/src/state/metrics.ts b/apps/studio/src/state/metrics.ts
new file mode 100644
index 0000000..817ca51
--- /dev/null
+++ b/apps/studio/src/state/metrics.ts
@@ -0,0 +1,198 @@
+import type { ExecState, IncidentKind, TaskExec, ToolCallExec, UsageExec } from '@runloop-world/core';
+import type { AnyEvent } from '@runloop-world/events';
+import { isTracedExec, usageSourceText, usageTotal } from './labels';
+
+/**
+ * Run metrics (D8), computed from the folded execution state and the event
+ * log only. Missing data stays missing: a value the source never reported is
+ * null (shown as "not reported"), never zero. Counts are of recorded events,
+ * so a count of 0 means "recorded none", which is a real value.
+ */
+
+export interface CountRow {
+ key: string;
+ count: number;
+ /** Calls that ended in an error or could not run. */
+ errors: number;
+}
+
+export interface AgentUsageRow {
+ agentId: string;
+ name: string;
+ /** Model as the source reported it (usage report, else registration); null = not reported. */
+ model: string | null;
+ /** The agent's latest usage report; null = not reported. */
+ usage: UsageExec | null;
+ /** Total tokens of that report; null = not reported. */
+ totalTokens: number | null;
+ /** "reported by Claude Code (Agent tool result)"; null when nothing was reported. */
+ reportedBy: string | null;
+}
+
+export interface RunMetrics {
+ traced: boolean;
+ events: number;
+ /** Run start to its end (or to the last event while running); null before run.started. */
+ duration: { ms: number; running: boolean } | null;
+ calls: {
+ total: number;
+ success: number;
+ /** The tool ran and reported an error (e.g. a failing test run). */
+ error: number;
+ /** The call never ran or its result never arrived (denied, interrupted, unobserved, ...). */
+ couldNotRun: number;
+ running: number;
+ /** Calls whose start or end Runloop reconstructed (inferred) instead of the source reporting it. */
+ inferred: number;
+ /** Calls with a category (traced runs); the rest are not categorised by their source. */
+ categorised: number;
+ byCategory: CountRow[];
+ byTool: CountRow[];
+ };
+ failures: {
+ total: number;
+ /** Not resolved yet: executed runs, its task has not finished since; traced runs, no later call of the same kind succeeded. */
+ open: number;
+ /** Resolved because a later call of the same kind succeeded (traced runs; inferred by Runloop). */
+ resolvedByLaterSuccess: number;
+ byKind: Partial>;
+ /** Sequence of the first failure, to jump to it. */
+ firstSeq: number | null;
+ };
+ /** null = no call reported test counts. */
+ tests: {
+ /** Calls that reported test counts. */
+ runs: number;
+ /** Of those, runs with at least one failing test. */
+ failingRuns: number;
+ latest: { tests: number; passed: number; failed: number; seq: number; callId: string };
+ } | null;
+ usage: {
+ agents: AgentUsageRow[];
+ /** Agents with a usage report. */
+ reportedAgents: number;
+ /** Sum of the reported totals; null when no agent reported usage (never 0 for "none"). */
+ reportedTotal: number | null;
+ };
+ permissions: {
+ requested: number;
+ approved: number;
+ denied: number;
+ pending: number;
+ /** Answers Runloop reconstructed (e.g. a denial no hook reported). */
+ inferred: number;
+ /** Waits with a recorded answer time; null when none (inferred answers have no real time). */
+ waits: { count: number; totalMs: number; longestMs: number } | null;
+ };
+ /** Prompts / turns (traced runs); null for executed runs. */
+ turns: number | null;
+ /** Work handed to subagents. */
+ delegations: number;
+ /** No source reports cost today. */
+ cost: null;
+}
+
+const ms = (iso: string | undefined | null): number => (iso ? Date.parse(iso) : NaN);
+
+function countBy(calls: readonly ToolCallExec[], keyOf: (c: ToolCallExec) => string | null): CountRow[] {
+ const rows = new Map();
+ for (const c of calls) {
+ const key = keyOf(c);
+ if (key === null) continue;
+ const row = rows.get(key) ?? { key, count: 0, errors: 0 };
+ row.count += 1;
+ if (c.status === 'error' || c.status === 'failed') row.errors += 1;
+ rows.set(key, row);
+ }
+ return [...rows.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
+}
+
+/** Metrics for a run from its latest folded state and its events (in sequence order). */
+export function computeRunMetrics(exec: ExecState, events: readonly AnyEvent[]): RunMetrics {
+ const calls = exec.callOrder.map((id) => exec.toolCalls.get(id)!).filter(Boolean);
+ const tasks = exec.taskOrder.map((id) => exec.tasks[id]!).filter(Boolean);
+ const timeOf = (seq: number | null) => (seq !== null ? ms(events[seq - 1]?.timestamp) : NaN);
+
+ // Duration: run start → run end, or → the last event while the run is still going.
+ const start = ms(exec.run.startedAt);
+ const ended = exec.run.endedAt !== null;
+ const end = ended ? ms(exec.run.endedAt) : ms(events.at(-1)?.timestamp);
+ const duration = Number.isFinite(start) && Number.isFinite(end) ? { ms: Math.max(0, end - start), running: !ended } : null;
+
+ // Tests: only counts the test runner reported (tool.call_completed metrics).
+ const testCalls = calls.filter((c) => c.metrics !== null && c.endedSeq !== null);
+ const latestTest = testCalls.reduce((a, c) => (a === null || c.endedSeq! > a.endedSeq! ? c : a), null);
+
+ // Usage: per agent, the latest report exactly as reported.
+ const agents: AgentUsageRow[] = exec.agentOrder
+ .map((id) => exec.agents[id]!)
+ .filter((a) => a && a.role !== 'user')
+ .map((a) => ({
+ agentId: a.id,
+ name: a.name,
+ model: a.usage?.model ?? a.model,
+ usage: a.usage,
+ totalTokens: a.usage ? usageTotal(a.usage) : null,
+ reportedBy: a.usage ? usageSourceText(a.usage.source) : null,
+ }));
+ const reported = agents.filter((a) => a.totalTokens !== null);
+
+ // Permissions: waits only where the answer time was recorded, not inferred.
+ const perms = Object.values(exec.permissions);
+ const timed = perms
+ .filter((p) => p.status !== 'pending' && p.resolvedProvenance !== 'inferred')
+ .map((p) => timeOf(p.resolvedSeq) - timeOf(p.requestedSeq))
+ .filter((d) => Number.isFinite(d) && d >= 0);
+
+ const byKind: Partial> = {};
+ for (const i of exec.incidents) byKind[i.kind] = (byKind[i.kind] ?? 0) + 1;
+ const ofKind = (k: TaskExec['kind']) => tasks.filter((t) => t.kind === k).length;
+ const traced = isTracedExec(exec);
+
+ return {
+ traced,
+ events: exec.lastSequence,
+ duration,
+ calls: {
+ total: calls.length,
+ success: calls.filter((c) => c.status === 'success').length,
+ error: calls.filter((c) => c.status === 'error').length,
+ couldNotRun: calls.filter((c) => c.status === 'failed').length,
+ running: calls.filter((c) => c.status === 'running').length,
+ inferred: calls.filter((c) => c.startProvenance === 'inferred' || c.endProvenance === 'inferred').length,
+ categorised: calls.filter((c) => c.category !== null).length,
+ byCategory: countBy(calls, (c) => c.category),
+ byTool: countBy(calls, (c) => c.tool),
+ },
+ failures: {
+ total: exec.incidents.length,
+ open: exec.incidents.filter((i) => i.resolvedSeq === null).length,
+ resolvedByLaterSuccess: exec.incidents.filter((i) => i.resolution === 'later_success').length,
+ byKind,
+ firstSeq: exec.incidents[0]?.seq ?? null,
+ },
+ tests: latestTest
+ ? {
+ runs: testCalls.length,
+ failingRuns: testCalls.filter((c) => c.metrics!.failed > 0).length,
+ latest: { ...latestTest.metrics!, seq: latestTest.endedSeq!, callId: latestTest.id },
+ }
+ : null,
+ usage: {
+ agents,
+ reportedAgents: reported.length,
+ reportedTotal: reported.length ? reported.reduce((sum, a) => sum + a.totalTokens!, 0) : null,
+ },
+ permissions: {
+ requested: perms.length,
+ approved: perms.filter((p) => p.status === 'approved').length,
+ denied: perms.filter((p) => p.status === 'denied').length,
+ pending: perms.filter((p) => p.status === 'pending').length,
+ inferred: perms.filter((p) => p.resolvedProvenance === 'inferred').length,
+ waits: timed.length ? { count: timed.length, totalMs: timed.reduce((a, b) => a + b, 0), longestMs: Math.max(...timed) } : null,
+ },
+ turns: traced ? ofKind('turn') : null,
+ delegations: ofKind('delegated'),
+ cost: null,
+ };
+}
diff --git a/apps/studio/src/state/onboarding.test.ts b/apps/studio/src/state/onboarding.test.ts
new file mode 100644
index 0000000..46bfdfe
--- /dev/null
+++ b/apps/studio/src/state/onboarding.test.ts
@@ -0,0 +1,36 @@
+import type { RunSummary } from '@runloop-world/events';
+import { describe, expect, it } from 'vitest';
+import { EMPTY_ONBOARDING, nextStep, parseOnboarding, stepsDone } from './onboarding';
+
+const base: RunSummary = {
+ runId: 'r1',
+ title: 't',
+ missionId: 'coding-mission',
+ worldId: 'research-campus',
+ status: 'succeeded',
+ createdAt: '2026-10-17T10:00:00.000Z',
+ endedAt: null,
+ lastSequence: 1,
+ adapterLabel: 'Scripted',
+ paused: false,
+};
+const traced: RunSummary = { ...base, runId: 'r2', mode: 'traced', source: { kind: 'claude-code', sessionId: 's', segment: 1, projectLabel: 'p' } };
+
+describe('getting-started checklist', () => {
+ it('parses stored progress tolerantly', () => {
+ expect(parseOnboarding(null)).toEqual(EMPTY_ONBOARDING);
+ expect(parseOnboarding('not json')).toEqual(EMPTY_ONBOARDING);
+ expect(parseOnboarding('{"opened":true,"replayed":"yes"}')).toEqual({ ...EMPTY_ONBOARDING, opened: true });
+ });
+
+ it('reads the first two steps from the runtime, the rest from this viewer', () => {
+ expect(stepsDone(EMPTY_ONBOARDING, [])).toEqual({ demo: false, connect: false, open: false, failure: false, replay: false });
+ expect(stepsDone({ ...EMPTY_ONBOARDING, replayed: true }, [base, traced])).toEqual({ demo: true, connect: true, open: false, failure: false, replay: true });
+ });
+
+ it('points at the first step not done, in order', () => {
+ expect(nextStep(stepsDone(EMPTY_ONBOARDING, [traced]))).toBe('demo');
+ expect(nextStep(stepsDone(EMPTY_ONBOARDING, [base]))).toBe('connect');
+ expect(nextStep(stepsDone({ opened: true, inspectedFailure: true, replayed: true, dismissed: false }, [base, traced]))).toBeNull();
+ });
+});
diff --git a/apps/studio/src/state/onboarding.ts b/apps/studio/src/state/onboarding.ts
new file mode 100644
index 0000000..1df03b3
--- /dev/null
+++ b/apps/studio/src/state/onboarding.ts
@@ -0,0 +1,46 @@
+import type { RunSummary } from '@runloop-world/events';
+import { isTracedRun } from './runs';
+
+/**
+ * The Overview's getting-started checklist. Steps 1–2 are read from the
+ * runtime's runs; steps 3–5 are what this viewer has done in this browser
+ * (a convenience kept in localStorage, never shared).
+ */
+export const ONBOARDING_STEPS = ['demo', 'connect', 'open', 'failure', 'replay'] as const;
+export type OnboardingStep = (typeof ONBOARDING_STEPS)[number];
+
+export interface OnboardingProgress {
+ opened: boolean;
+ inspectedFailure: boolean;
+ replayed: boolean;
+ dismissed: boolean;
+}
+
+export const EMPTY_ONBOARDING: OnboardingProgress = { opened: false, inspectedFailure: false, replayed: false, dismissed: false };
+export const ONBOARDING_KEY = 'runloop.onboarding.v1';
+
+export function parseOnboarding(raw: string | null): OnboardingProgress {
+ try {
+ const v: unknown = raw ? JSON.parse(raw) : null;
+ if (!v || typeof v !== 'object') return EMPTY_ONBOARDING;
+ const o = v as Record;
+ return { opened: o.opened === true, inspectedFailure: o.inspectedFailure === true, replayed: o.replayed === true, dismissed: o.dismissed === true };
+ } catch {
+ return EMPTY_ONBOARDING;
+ }
+}
+
+export function stepsDone(p: OnboardingProgress, runs: readonly RunSummary[]): Record {
+ return {
+ demo: runs.some((r) => !isTracedRun(r)),
+ connect: runs.some(isTracedRun),
+ open: p.opened,
+ failure: p.inspectedFailure,
+ replay: p.replayed,
+ };
+}
+
+/** The first step not done yet, in order; null when all are done. */
+export function nextStep(done: Record): OnboardingStep | null {
+ return ONBOARDING_STEPS.find((s) => !done[s]) ?? null;
+}
diff --git a/apps/studio/src/state/playback.test.ts b/apps/studio/src/state/playback.test.ts
new file mode 100644
index 0000000..e46cb23
--- /dev/null
+++ b/apps/studio/src/state/playback.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { advancePlayback, MAX_FRAME_MS } from './playback';
+
+const replay = (tMs: number, speed: 0.5 | 1 | 2 | 4 = 1) => ({ mode: 'replay' as const, tMs, seq: 3, playing: true, speed });
+
+describe('playback pacing', () => {
+ it('scales wall-clock time by speed', () => {
+ expect(advancePlayback(replay(1000, 1), 100, 10_000).tMs).toBe(1100);
+ expect(advancePlayback(replay(1000, 0.5), 100, 10_000).tMs).toBe(1050);
+ expect(advancePlayback(replay(1000, 4), 100, 10_000).tMs).toBe(1400);
+ });
+
+ it('caps one frame so a hidden tab does not leap ahead', () => {
+ expect(advancePlayback(replay(0, 2), 60_000, 100_000).tMs).toBe(MAX_FRAME_MS * 2);
+ expect(advancePlayback(replay(500), -20, 1000).tMs).toBe(500);
+ });
+
+ it('stops at the end', () => {
+ const c = advancePlayback(replay(9950, 4), 100, 10_000);
+ expect(c).toEqual({ mode: 'replay', tMs: 10_000, seq: 3, playing: false, speed: 4 });
+ const paused = { ...replay(10), playing: false };
+ expect(advancePlayback(paused, 100, 1000)).toBe(paused);
+ });
+});
diff --git a/apps/studio/src/state/playback.ts b/apps/studio/src/state/playback.ts
new file mode 100644
index 0000000..bbc3593
--- /dev/null
+++ b/apps/studio/src/state/playback.ts
@@ -0,0 +1,20 @@
+import type { Cursor } from './store';
+
+type ReplayCursor = Extract;
+
+/**
+ * Longest wall-clock step one frame may take: playback keeps real time down to
+ * ~1 fps, but a tab returning from the background does not leap ahead.
+ */
+export const MAX_FRAME_MS = 1000;
+
+/**
+ * Move a playing replay cursor forward by `dtMs` of wall-clock time, scaled by
+ * its speed. Stops (playing = false) at `endMs`. Paused cursors are returned as is.
+ */
+export function advancePlayback(cursor: ReplayCursor, dtMs: number, endMs: number): ReplayCursor {
+ if (!cursor.playing) return cursor;
+ const dt = Math.max(0, Math.min(MAX_FRAME_MS, dtMs));
+ const tMs = Math.min(endMs, cursor.tMs + dt * cursor.speed);
+ return { ...cursor, tMs, playing: tMs < endMs };
+}
diff --git a/apps/studio/src/state/prefs.test.ts b/apps/studio/src/state/prefs.test.ts
new file mode 100644
index 0000000..b6c8ae9
--- /dev/null
+++ b/apps/studio/src/state/prefs.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_PREFS, effectiveReducedMotion, parsePrefs, PREFS_KEY, resolveTheme, THEME_INIT_SCRIPT } from './prefs';
+
+describe('viewer preferences', () => {
+ it('parses stored prefs and falls back on anything broken', () => {
+ expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
+ expect(parsePrefs('not json')).toEqual(DEFAULT_PREFS);
+ expect(parsePrefs('{"theme":"light","motion":"reduce","quality":"low","apiBase":"http://127.0.0.1:4392"}')).toEqual({
+ theme: 'light',
+ motion: 'reduce',
+ quality: 'low',
+ apiBase: 'http://127.0.0.1:4392',
+ });
+ expect(parsePrefs('{"theme":"neon","quality":3}')).toEqual(DEFAULT_PREFS);
+ });
+
+ it('resolves the theme and reduced motion against the system', () => {
+ expect(resolveTheme('system', true)).toBe('light');
+ expect(resolveTheme('system', false)).toBe('dark');
+ expect(resolveTheme('dark', true)).toBe('dark');
+ expect(effectiveReducedMotion('system', true)).toBe(true);
+ expect(effectiveReducedMotion('full', true)).toBe(false);
+ expect(effectiveReducedMotion('reduce', false)).toBe(true);
+ });
+
+ it('sets data-theme and data-motion before paint from storage', () => {
+ const run = (stored: string | null, prefersLight: boolean) => {
+ const attrs: Record = {};
+ const env = {
+ document: { documentElement: { setAttribute: (k: string, v: string) => (attrs[k] = v) } },
+ localStorage: { getItem: (k: string) => (k === PREFS_KEY ? stored : null) },
+ window: { matchMedia: () => ({ matches: prefersLight }) },
+ matchMedia: () => ({ matches: prefersLight }),
+ };
+ new Function('document', 'localStorage', 'window', 'matchMedia', THEME_INIT_SCRIPT)(env.document, env.localStorage, env.window, env.matchMedia);
+ return attrs;
+ };
+ expect(run(null, false)).toEqual({ 'data-theme': 'dark' });
+ expect(run(null, true)).toEqual({ 'data-theme': 'light' });
+ expect(run('{"theme":"dark","motion":"reduce"}', true)).toEqual({ 'data-theme': 'dark', 'data-motion': 'reduce' });
+ expect(run('{oops', true)).toEqual({ 'data-theme': 'light' });
+ });
+});
diff --git a/apps/studio/src/state/prefs.ts b/apps/studio/src/state/prefs.ts
new file mode 100644
index 0000000..eb3df14
--- /dev/null
+++ b/apps/studio/src/state/prefs.ts
@@ -0,0 +1,79 @@
+import type { Quality } from '../scene/types';
+
+export type ThemePref = 'system' | 'dark' | 'light';
+export type MotionPref = 'system' | 'reduce' | 'full';
+
+/** Viewer preferences, kept in this browser only (localStorage). */
+export interface Prefs {
+ theme: ThemePref;
+ motion: MotionPref;
+ quality: Quality;
+ /** Runtime URL override; null = the build default. */
+ apiBase: string | null;
+}
+
+export const DEFAULT_PREFS: Prefs = { theme: 'system', motion: 'system', quality: 'high', apiBase: null };
+
+export const PREFS_KEY = 'runloop.prefs.v1';
+
+const pick = (v: unknown, allowed: readonly T[], fallback: T): T =>
+ typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
+
+/** Tolerant parse: unknown or broken values fall back to the defaults. */
+export function parsePrefs(raw: string | null): Prefs {
+ let v: Record = {};
+ try {
+ const parsed: unknown = raw ? JSON.parse(raw) : {};
+ if (parsed && typeof parsed === 'object') v = parsed as Record;
+ } catch {
+ // keep defaults
+ }
+ return {
+ theme: pick(v.theme, ['system', 'dark', 'light'], DEFAULT_PREFS.theme),
+ motion: pick(v.motion, ['system', 'reduce', 'full'], DEFAULT_PREFS.motion),
+ quality: pick(v.quality, ['high', 'low'], DEFAULT_PREFS.quality),
+ apiBase: typeof v.apiBase === 'string' && v.apiBase ? v.apiBase : null,
+ };
+}
+
+/** localStorage can throw (private windows, blocked storage); never let that break the page. */
+export const safeStorage = {
+ getItem(key: string): string | null {
+ try {
+ return window.localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ },
+ setItem(key: string, value: string): void {
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ // not persisted; the page keeps working
+ }
+ },
+};
+
+export function loadPrefs(): Prefs {
+ return parsePrefs(safeStorage.getItem(PREFS_KEY));
+}
+
+export function savePrefs(p: Prefs): void {
+ safeStorage.setItem(PREFS_KEY, JSON.stringify(p));
+}
+
+/**
+ * Runs in before first paint so the page never flashes the wrong
+ * theme. Mirrors parsePrefs/applyTheme; keep the key in sync with PREFS_KEY.
+ */
+export const THEME_INIT_SCRIPT = `(function(){var d=document.documentElement,p={};try{p=JSON.parse(localStorage.getItem(${JSON.stringify(
+ PREFS_KEY,
+)})||'{}')||{}}catch(e){}var t=p.theme;if(t!=='light'&&t!=='dark'){t=window.matchMedia&&matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'}d.setAttribute('data-theme',t);if(p.motion==='reduce'||p.motion==='full')d.setAttribute('data-motion',p.motion)})()`;
+
+export function resolveTheme(pref: ThemePref, systemPrefersLight: boolean): 'dark' | 'light' {
+ return pref === 'system' ? (systemPrefersLight ? 'light' : 'dark') : pref;
+}
+
+export function effectiveReducedMotion(pref: MotionPref, systemPrefersReduced: boolean): boolean {
+ return pref === 'reduce' || (pref === 'system' && systemPrefersReduced);
+}
diff --git a/apps/studio/src/state/provenance.ts b/apps/studio/src/state/provenance.ts
new file mode 100644
index 0000000..af77d53
--- /dev/null
+++ b/apps/studio/src/state/provenance.ts
@@ -0,0 +1,66 @@
+import type { ExecState } from '@runloop-world/core';
+import type { RunSummary } from '@runloop-world/events';
+import type { Tone } from '../ui/Chip';
+import type { IconName } from '../ui/Icon';
+
+/** design-system.md, Provenance labelling. */
+export interface ProvenanceView {
+ kind: 'executed' | 'traced' | 'synthetic';
+ label: string;
+ detail: string;
+ icon: IconName;
+ tone: Tone;
+}
+
+const SOURCE_LABEL: Record = { 'claude-code': 'Claude Code' };
+
+/** The honest provenance banner for traced runs (docs/plans/claude-code-tracer.md §7.3). */
+export function tracedBanner(source: string): string {
+ const from = source === 'Claude Code' ? 'Claude Code hooks' : source;
+ return `Recorded from ${from}. Runloop did not run, approve or verify these actions; results are shown as ${source} reported them. Items marked inferred were reconstructed by Runloop.`;
+}
+
+const field = (o: unknown, key: string): unknown => (o && typeof o === 'object' ? (o as Record)[key] : undefined);
+
+/**
+ * Where a run's events come from. Traced runs (adapter kind 'external' or
+ * mode 'traced', from the Claude Code tracer) were recorded, not executed by
+ * Runloop. Reads the newer fields defensively so older and newer runtimes both work.
+ */
+export function runProvenance(run: RunSummary | null, exec: ExecState): ProvenanceView {
+ const mode = field(run, 'mode') ?? field(exec.run, 'mode');
+ const sourceKind = field(field(run, 'source') ?? field(exec.run, 'source'), 'kind');
+ if (mode === 'synthetic') {
+ return { kind: 'synthetic', label: 'Synthetic', detail: 'Example or fixture data, never mixed with real runs.', icon: 'diamond', tone: 'warn' };
+ }
+ const adapterKind: unknown = exec.run.adapter?.kind;
+ if (mode === 'traced' || adapterKind === 'external') {
+ const source = typeof sourceKind === 'string' ? (SOURCE_LABEL[sourceKind] ?? sourceKind) : 'external source';
+ return {
+ kind: 'traced',
+ label: `Traced · ${source}`,
+ detail: tracedBanner(source),
+ icon: 'eye',
+ tone: 'info',
+ };
+ }
+ const scripted = adapterKind === 'scripted' || (!exec.run.adapter && /scripted/i.test(run?.adapterLabel ?? ''));
+ return {
+ kind: 'executed',
+ label: 'Executed',
+ detail: scripted
+ ? "Runloop's runtime ran the tools itself. Agent decisions come from a scripted adapter, not a model."
+ : "Runloop's runtime ran the tools itself.",
+ icon: 'terminal',
+ tone: 'muted',
+ };
+}
+
+/** Event-level chip for events Runloop reconstructed. */
+export const INFERRED: ProvenanceView = {
+ kind: 'traced',
+ label: 'Inferred',
+ detail: 'Reconstructed by Runloop (for example a denied permission); the source did not report it directly.',
+ icon: 'inferred',
+ tone: 'warn',
+};
diff --git a/apps/studio/src/state/runs.test.ts b/apps/studio/src/state/runs.test.ts
new file mode 100644
index 0000000..1488862
--- /dev/null
+++ b/apps/studio/src/state/runs.test.ts
@@ -0,0 +1,105 @@
+import type { RunSummary } from '@runloop-world/events';
+import { describe, expect, it } from 'vitest';
+import { activityText, formatAgo, groupRuns, idleMs, isTracedRun, lastTracedEventAt, pickRunToOpen, runSourceLabel, runSubtitle } from './runs';
+import { runStatusView } from './view';
+
+const NOW = Date.parse('2026-10-17T12:00:00.000Z');
+
+function run(id: string, over: Partial = {}): RunSummary {
+ return {
+ runId: id,
+ title: `Run ${id}`,
+ missionId: 'coding-mission',
+ worldId: 'research-campus',
+ status: 'succeeded',
+ createdAt: '2026-10-17T11:00:00.000Z',
+ endedAt: '2026-10-17T11:05:00.000Z',
+ lastSequence: 10,
+ adapterLabel: 'Scripted adapter',
+ paused: false,
+ ...over,
+ };
+}
+
+const traced = (id: string, over: Partial = {}) =>
+ run(id, {
+ title: 'Claude Code · demo',
+ missionId: 'claude-code',
+ mode: 'traced',
+ source: { kind: 'claude-code', sessionId: `${id}-0000-session`, segment: 1, projectLabel: 'demo' },
+ ...over,
+ });
+
+describe('run kinds and labels', () => {
+ it('tells traced runs from executed ones, also without the newer fields', () => {
+ expect(isTracedRun(traced('a'))).toBe(true);
+ expect(isTracedRun(run('b'))).toBe(false);
+ expect(isTracedRun(run('c', { mode: 'executed', source: null }))).toBe(false);
+ expect(runSourceLabel(traced('a'))).toBe('Traced · Claude Code');
+ expect(runSourceLabel(run('b'))).toBe('Executed');
+ });
+
+ it('subtitles traced runs with project, short session id and segment', () => {
+ expect(runSubtitle(traced('abcdef12'))).toBe('demo · session abcdef12');
+ expect(runSubtitle(traced('abcdef12', { source: { kind: 'claude-code', sessionId: 'abcdef1234', segment: 2, projectLabel: 'web' } }))).toBe(
+ 'web · session abcdef12 · part 2',
+ );
+ expect(runSubtitle(run('b'))).toBeNull();
+ });
+
+ it('shows an ended traced session as "ended", not "stopped"', () => {
+ expect(runStatusView('stopped', false, true)).toMatchObject({ text: 'ended', icon: '■' });
+ expect(runStatusView('stopped')).toMatchObject({ text: 'stopped' });
+ expect(runStatusView('interrupted', false, true).text).toBe('interrupted');
+ });
+});
+
+describe('idle time', () => {
+ it('is measured from the last recorded event', () => {
+ const r = traced('a', { status: 'running', lastEventAt: '2026-10-17T11:55:00.000Z' });
+ expect(idleMs(r, NOW)).toBe(5 * 60_000);
+ expect(activityText(r, NOW)).toBe('idle 5m');
+ expect(activityText({ ...r, lastEventAt: '2026-10-17T11:59:30.000Z' }, NOW)).toBe('active');
+ });
+
+ it('is unknown (null), not zero, when the runtime does not report it', () => {
+ expect(idleMs(traced('a', { lastEventAt: null }), NOW)).toBeNull();
+ expect(activityText(run('b'), NOW)).toBeNull();
+ });
+
+ it('formats', () => {
+ expect(formatAgo(42_000)).toBe('<1m');
+ expect(formatAgo(5 * 60_000)).toBe('5m');
+ expect(formatAgo(125 * 60_000)).toBe('2h 05m');
+ expect(formatAgo(50 * 3600_000)).toBe('2d');
+ });
+});
+
+describe('grouping and following', () => {
+ const runs = [
+ traced('t3', { status: 'running', lastEventAt: '2026-10-17T11:58:00.000Z' }),
+ run('d2', { status: 'running', endedAt: null }),
+ traced('t2', { status: 'stopped', lastEventAt: '2026-10-17T11:50:00.000Z' }),
+ run('d1'),
+ traced('t1', { status: 'interrupted', lastEventAt: '2026-10-17T10:00:00.000Z' }),
+ ];
+
+ it('groups into live sessions (traced first), traced history and demo history', () => {
+ const g = groupRuns(runs);
+ expect(g.live.map((r) => r.runId)).toEqual(['t3', 'd2']);
+ expect(g.traced.map((r) => r.runId)).toEqual(['t2', 't1']);
+ expect(g.demo.map((r) => r.runId)).toEqual(['d1']);
+ });
+
+ it('opens the active demo, else the newest live traced session, else the newest run', () => {
+ expect(pickRunToOpen('d2', ['t3'], runs)).toBe('d2');
+ expect(pickRunToOpen(null, ['t3'], runs)).toBe('t3');
+ expect(pickRunToOpen(null, [], runs)).toBe('t3');
+ expect(pickRunToOpen(null, [], [])).toBeNull();
+ });
+
+ it('finds when Claude Code last sent anything', () => {
+ expect(lastTracedEventAt(runs)).toBe('2026-10-17T11:58:00.000Z');
+ expect(lastTracedEventAt([run('d1')])).toBeNull();
+ });
+});
diff --git a/apps/studio/src/state/runs.ts b/apps/studio/src/state/runs.ts
new file mode 100644
index 0000000..d9b926a
--- /dev/null
+++ b/apps/studio/src/state/runs.ts
@@ -0,0 +1,96 @@
+import type { RunSummary } from '@runloop-world/events';
+
+/**
+ * Run-list helpers that work from summaries alone (the lists never load each
+ * run's events). Newer runtime fields (mode, source, lastEventAt) are read
+ * defensively so older runtimes still work.
+ */
+
+/** Recorded from an external agent (Claude Code), as opposed to executed by Runloop. */
+export function isTracedRun(run: RunSummary | null | undefined): boolean {
+ return !!run && (run.mode === 'traced' || (run.source !== undefined && run.source !== null));
+}
+
+const SOURCE_LABEL: Record = { 'claude-code': 'Claude Code' };
+
+/** "Traced · Claude Code" or "Executed" (design-system.md, Provenance labelling). */
+export function runSourceLabel(run: RunSummary): string {
+ if (!isTracedRun(run)) return 'Executed';
+ const kind = run.source?.kind;
+ return `Traced · ${kind ? (SOURCE_LABEL[kind] ?? kind) : 'external source'}`;
+}
+
+/** First 8 characters of a session id (enough to tell sessions apart). */
+export function shortId(id: string): string {
+ return id.slice(0, 8);
+}
+
+/** A second line that tells same-titled runs apart: "demo · session 448e50c5 · part 2". */
+export function runSubtitle(run: RunSummary): string | null {
+ const src = run.source;
+ if (!src) return null;
+ return `${src.projectLabel} · session ${shortId(src.sessionId)}${src.segment > 1 ? ` · part ${src.segment}` : ''}`;
+}
+
+/** Time since the run's last recorded event; null when the runtime does not report it. */
+export function idleMs(run: RunSummary, nowMs: number): number | null {
+ const t = run.lastEventAt ? Date.parse(run.lastEventAt) : NaN;
+ return Number.isFinite(t) ? Math.max(0, nowMs - t) : null;
+}
+
+/** 42 s → "<1m", 5 min → "5m", 125 min → "2h 05m", 50 h → "2d". */
+export function formatAgo(ms: number): string {
+ const min = Math.floor(ms / 60_000);
+ if (min < 1) return '<1m';
+ if (min < 60) return `${min}m`;
+ const h = Math.floor(min / 60);
+ if (h < 48) return `${h}h ${String(min % 60).padStart(2, '0')}m`;
+ return `${Math.floor(h / 24)}d`;
+}
+
+/** Idle this long or more reads as "idle"; shorter means events are still arriving. */
+export const IDLE_AFTER_MS = 60_000;
+
+/** "active" while events keep arriving, "idle 5m" after a minute without any; null if unknown. */
+export function activityText(run: RunSummary, nowMs: number): string | null {
+ const idle = idleMs(run, nowMs);
+ if (idle === null) return null;
+ return idle < IDLE_AFTER_MS ? 'active' : `idle ${formatAgo(idle)}`;
+}
+
+export interface RunGroups {
+ /** Recording right now (traced sessions first, then a running demo). */
+ live: RunSummary[];
+ /** Finished Claude Code sessions. */
+ traced: RunSummary[];
+ /** Finished scripted demo runs (executed by Runloop). */
+ demo: RunSummary[];
+}
+
+/** Split runs (newest first) into live sessions, traced history and demo history; order is kept. */
+export function groupRuns(runs: readonly RunSummary[]): RunGroups {
+ const live = runs.filter((r) => r.status === 'running');
+ return {
+ live: [...live.filter(isTracedRun), ...live.filter((r) => !isTracedRun(r))],
+ traced: runs.filter((r) => r.status !== 'running' && isTracedRun(r)),
+ demo: runs.filter((r) => r.status !== 'running' && !isTracedRun(r)),
+ };
+}
+
+/**
+ * The run to open when nothing is open (plan §7.4): the active scripted run,
+ * else the newest live traced session, else the newest run.
+ */
+export function pickRunToOpen(activeRunId: string | null, tracedRunIds: readonly string[], runs: readonly RunSummary[]): string | null {
+ return activeRunId ?? tracedRunIds[0] ?? runs[0]?.runId ?? null;
+}
+
+/** Newest lastEventAt among traced runs (when Claude Code last sent anything recorded). */
+export function lastTracedEventAt(runs: readonly RunSummary[]): string | null {
+ let best: string | null = null;
+ for (const r of runs) {
+ if (!isTracedRun(r) || !r.lastEventAt) continue;
+ if (best === null || Date.parse(r.lastEventAt) > Date.parse(best)) best = r.lastEventAt;
+ }
+ return best;
+}
diff --git a/apps/studio/src/state/selection.test.ts b/apps/studio/src/state/selection.test.ts
new file mode 100644
index 0000000..066ecdb
--- /dev/null
+++ b/apps/studio/src/state/selection.test.ts
@@ -0,0 +1,185 @@
+import { foldExecution } from '@runloop-world/core';
+import { describe, expect, it } from 'vitest';
+import { agentOfEvent, artifactIdsOf, latestEventOf, parentChain } from './selection';
+import { openFixture, wire } from './test-utils';
+
+/** Fixture landmarks (codingMissionFixture): see the sequence comments below. */
+const SEQ = {
+ runStarted: 1,
+ taskT1Created: 6, // actor: coordinator
+ t1Completed: 19, // no actor; owner researcher
+ call5Started: 26, // builder write_file
+ art1: 27, // artifact of call-5
+ call5Completed: 28,
+ testsFailed: 36, // tester run_command, status error
+ runCompleted: 58,
+};
+
+function loaded() {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.map(wire), 'backlog');
+ return { store, events, exec: foldExecution(events) };
+}
+
+describe('agentOfEvent', () => {
+ const { events, exec } = loaded();
+ const at = (seq: number) => events[seq - 1]!;
+
+ it('attributes call results, artifacts and task results to the agent behind them', () => {
+ expect(agentOfEvent(at(SEQ.call5Completed), exec)).toBe('builder');
+ expect(agentOfEvent(at(SEQ.art1), exec)).toBe('builder');
+ expect(agentOfEvent(at(SEQ.testsFailed), exec)).toBe('tester');
+ expect(agentOfEvent(at(SEQ.t1Completed), exec)).toBe('researcher');
+ });
+
+ it('keeps the creator as the agent of task.created, and null for system events', () => {
+ expect(agentOfEvent(at(SEQ.taskT1Created), exec)).toBe('coordinator');
+ expect(agentOfEvent(at(SEQ.runStarted), exec)).toBeNull();
+ expect(agentOfEvent(at(SEQ.runCompleted), exec)).toBeNull();
+ });
+
+ it('finds the latest event of an agent up to a sequence', () => {
+ expect(latestEventOf(events, exec, 'builder', SEQ.call5Completed)).toBe(SEQ.call5Completed);
+ expect(latestEventOf(events, exec, 'tester', 30)).toBe(5); // only its registration so far
+ expect(latestEventOf(events, exec, 'nobody', 58)).toBeNull();
+ });
+
+ it('follows parent links and lists artifacts', () => {
+ const byId = new Map(events.map((e) => [e.eventId, e]));
+ expect(parentChain(at(SEQ.call5Completed), byId).map((e) => e.sequence)).toEqual([SEQ.call5Started]);
+ expect(artifactIdsOf(at(SEQ.call5Completed))).toEqual(['art-1']);
+ });
+});
+
+describe('synchronised selection (store)', () => {
+ it('selecting a timeline event focuses its agent and station and opens the event', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(SEQ.testsFailed, { seek: true });
+ const s = store.getState();
+ expect(s.selectedSeq).toBe(SEQ.testsFailed);
+ expect(s.selectedAgentId).toBe('tester');
+ expect(s.inspectorTab).toBe('event');
+ // The station is the tester's station in the world state right after that event.
+ expect(s.selectedStationId).toBe(s.stateAt(SEQ.testsFailed).world.characters.tester?.stationId ?? null);
+ expect(s.selectedStationId).not.toBeNull();
+ // seek: the playhead is at that event (replay, paused).
+ expect(s.cursor).toMatchObject({ mode: 'replay', playing: false });
+ expect(s.timeOfSeq(s.playheadSeq)).toBe(s.timeOfSeq(SEQ.testsFailed));
+ });
+
+ it('selecting without seek leaves the playhead alone', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(SEQ.call5Completed);
+ expect(store.getState().cursor).toEqual({ mode: 'live' });
+ expect(store.getState().selectedAgentId).toBe('builder');
+ });
+
+ it('a system event clears the agent and station', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(SEQ.testsFailed);
+ store.getState().selectEvent(SEQ.runStarted);
+ const s = store.getState();
+ expect(s).toMatchObject({ selectedSeq: SEQ.runStarted, selectedAgentId: null, selectedStationId: null, inspectorTab: 'event' });
+ });
+
+ it('selecting a character opens its card and highlights its latest event at the playhead', () => {
+ const { store } = loaded();
+ store.getState().seekToSeq(30);
+ store.getState().selectAgent('builder');
+ const s = store.getState();
+ expect(s.selectedAgentId).toBe('builder');
+ expect(s.inspectorTab).toBe('agent');
+ expect(s.selectedSeq).toBe(30); // agent.state_changed builder at #30
+ expect(s.selectedStationId).toBe(s.stateAt(s.playheadSeq).world.characters.builder?.stationId ?? null);
+ });
+
+ it('deselecting the character keeps the selected event', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(SEQ.call5Completed);
+ store.getState().selectAgent(null);
+ expect(store.getState()).toMatchObject({ selectedSeq: SEQ.call5Completed, selectedAgentId: null, inspectorTab: 'event' });
+ });
+
+ it('cycling characters selects each one and its latest event', () => {
+ const { store, events, exec } = loaded();
+ store.getState().cycleAgent(1);
+ expect(store.getState().selectedAgentId).toBe('researcher');
+ expect(store.getState().selectedSeq).toBe(latestEventOf(events, exec, 'researcher', events.length));
+ store.getState().cycleAgent(1);
+ expect(store.getState().selectedAgentId).toBe('builder');
+ });
+
+ it('ignores events that do not exist and clears everything on demand', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(999);
+ expect(store.getState().selectedSeq).toBeNull();
+ store.getState().selectEvent(SEQ.testsFailed);
+ store.getState().clearSelection();
+ expect(store.getState()).toMatchObject({ selectedSeq: null, selectedAgentId: null, selectedStationId: null, inspectorTab: 'run' });
+ });
+
+ it('opening another run resets the selection', () => {
+ const { store } = loaded();
+ store.getState().selectEvent(SEQ.testsFailed);
+ store.getState().openRun('00000000-0000-4000-8000-000000000000');
+ expect(store.getState()).toMatchObject({ selectedSeq: null, selectedAgentId: null, inspectorTab: 'run', pendingFocus: null });
+ });
+});
+
+describe('deep-link focus', () => {
+ it('waits for the event to arrive, then selects it and moves the playhead there', () => {
+ const { store, events } = openFixture();
+ store.getState().focusDeepLink({ seq: SEQ.testsFailed, agentId: null });
+ expect(store.getState().selectedSeq).toBeNull();
+ store.getState().ingest(events.slice(0, 20).map(wire), 'live');
+ expect(store.getState().pendingFocus).not.toBeNull();
+ store.getState().ingest(events.slice(20).map(wire), 'live');
+ const s = store.getState();
+ expect(s.pendingFocus).toBeNull();
+ expect(s).toMatchObject({ selectedSeq: SEQ.testsFailed, selectedAgentId: 'tester', inspectorTab: 'event' });
+ expect(s.cursor.mode).toBe('replay');
+ });
+
+ it('an event is shown with its own agent, whatever agent the link names', () => {
+ const { store } = loaded();
+ store.getState().focusDeepLink({ seq: SEQ.testsFailed, agentId: 'builder' });
+ expect(store.getState()).toMatchObject({ selectedSeq: SEQ.testsFailed, selectedAgentId: 'tester', inspectorTab: 'event' });
+ });
+
+ it('an agent alone selects it like a click on its character, without moving the playhead', () => {
+ const { store } = loaded();
+ store.getState().focusDeepLink({ seq: null, agentId: 'tester' });
+ expect(store.getState()).toMatchObject({ selectedAgentId: 'tester', inspectorTab: 'agent', cursor: { mode: 'live' } });
+ });
+
+ it('restores a paused playhead and the agent card together (a reload after selecting an agent)', () => {
+ const { store, events } = loaded();
+ store.getState().seekToSeq(30);
+ store.getState().selectAgent('builder');
+ const before = store.getState();
+ const { store: again } = openFixture();
+ again.getState().focusDeepLink({ seq: null, agentId: 'builder', at: 30 });
+ again.getState().ingest(events.map(wire), 'backlog');
+ expect(again.getState()).toMatchObject({
+ playheadSeq: 30,
+ selectedAgentId: 'builder',
+ selectedSeq: before.selectedSeq,
+ inspectorTab: 'agent',
+ cursor: { mode: 'replay', playing: false },
+ });
+ });
+
+ it('keeps a live run live when the link names no playhead', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.slice(0, 40).map(wire), 'live');
+ store.getState().focusDeepLink({ seq: null, agentId: 'builder', at: null });
+ expect(store.getState()).toMatchObject({ selectedAgentId: 'builder', cursor: { mode: 'live' } });
+ });
+
+ it('is dropped once the run has ended without that event', () => {
+ const { store } = loaded();
+ store.getState().focusDeepLink({ seq: 500, agentId: null });
+ expect(store.getState().pendingFocus).toBeNull();
+ expect(store.getState().selectedSeq).toBeNull();
+ });
+});
diff --git a/apps/studio/src/state/selection.ts b/apps/studio/src/state/selection.ts
new file mode 100644
index 0000000..afad1b4
--- /dev/null
+++ b/apps/studio/src/state/selection.ts
@@ -0,0 +1,114 @@
+import type { ExecState } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent } from '@runloop-world/events';
+
+/** What the inspector shows: the selected event, the selected agent's card, or the run summary. */
+export type InspectorTab = 'event' | 'agent' | 'run';
+
+const known = (exec: ExecState, id: string | null | undefined): string | null => (id && exec.agents[id] ? id : null);
+
+/**
+ * The registered agent an event is about: its actor, else the agent behind
+ * its tool call, permission, artifact or task. Null for system events and
+ * unknown ids. `exec` should be the latest state (ids only ever get added).
+ */
+export function agentOfEvent(e: AnyEvent, exec: ExecState): string | null {
+ const actor = known(exec, e.actorId);
+ const taskOwner = () => (e.taskId ? known(exec, exec.tasks[e.taskId]?.ownerId) : null);
+ if (isUnknownEvent(e)) return actor ?? taskOwner();
+ switch (e.type) {
+ case 'tool.call_completed':
+ case 'tool.call_failed':
+ return known(exec, exec.toolCalls.get(e.payload.callId)?.agentId) ?? actor;
+ case 'tool.permission_resolved':
+ return known(exec, exec.permissions[e.payload.permissionId]?.agentId) ?? actor;
+ case 'artifact.created': {
+ const viaCall = e.payload.producedByCallId ? exec.toolCalls.get(e.payload.producedByCallId)?.agentId : null;
+ return actor ?? known(exec, exec.artifacts[e.payload.artifactId]?.agentId) ?? known(exec, viaCall);
+ }
+ case 'task.created':
+ case 'task.blocked':
+ case 'task.completed':
+ case 'task.failed': {
+ // task.created's actor is the creator (e.g. the coordinator); the task is about its owner.
+ const owner = known(exec, exec.tasks[e.payload.taskId]?.ownerId);
+ return e.type === 'task.created' ? (actor ?? owner) : (owner ?? actor);
+ }
+ default:
+ return actor ?? taskOwner();
+ }
+}
+
+/** Sequence of the latest event about `agentId` at or before `uptoSeq`, or null. */
+export function latestEventOf(events: readonly AnyEvent[], exec: ExecState, agentId: string, uptoSeq: number): number | null {
+ for (let i = Math.min(uptoSeq, events.length) - 1; i >= 0; i--) {
+ const e = events[i]!;
+ if (agentOfEvent(e, exec) === agentId) return e.sequence;
+ }
+ return null;
+}
+
+/** Causal ancestors via parentEventId, nearest first (bounded, cycle-safe). */
+export function parentChain(e: AnyEvent, byId: ReadonlyMap, max = 10): AnyEvent[] {
+ const out: AnyEvent[] = [];
+ const seen = new Set([e.eventId]);
+ for (let p = e.parentEventId ? byId.get(e.parentEventId) : undefined; p && out.length < max && !seen.has(p.eventId); ) {
+ out.push(p);
+ seen.add(p.eventId);
+ p = p.parentEventId ? byId.get(p.parentEventId) : undefined;
+ }
+ return out;
+}
+
+/** Artifact ids an event points at (artifact.created, tool results, task results). */
+export function artifactIdsOf(e: AnyEvent): string[] {
+ if (isUnknownEvent(e)) return [];
+ if (e.type === 'artifact.created') return [e.payload.artifactId];
+ if (e.type === 'tool.call_completed') return e.payload.outputArtifactIds ?? [];
+ if (e.type === 'task.completed') return e.payload.artifactIds;
+ return [];
+}
+
+/** The tool call an event belongs to, if any. */
+export function callIdOf(e: AnyEvent): string | null {
+ if (isUnknownEvent(e)) return null;
+ switch (e.type) {
+ case 'tool.call_started':
+ case 'tool.call_completed':
+ case 'tool.call_failed':
+ return e.payload.callId;
+ case 'tool.permission_requested':
+ return e.payload.callId ?? null;
+ case 'artifact.created':
+ return e.payload.producedByCallId ?? null;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Event-level provenance, when the source sets one (tracer events carry
+ * 'reported' or 'inferred'; events the runtime executed carry none).
+ */
+export function eventProvenance(e: AnyEvent): 'reported' | 'inferred' | null {
+ const p = (e as { provenance?: unknown }).provenance;
+ return p === 'reported' || p === 'inferred' ? p : null;
+}
+
+/**
+ * The subagent an `Agent` call started: the owner of the delegated task whose
+ * task.created points at the call's start (parentEventId). Null otherwise.
+ */
+export function subagentOfCall(events: readonly AnyEvent[], exec: ExecState, startedEventId: string): string | null {
+ for (const e of events) {
+ if (isUnknownEvent(e) || e.type !== 'task.created' || e.parentEventId !== startedEventId) continue;
+ return known(exec, exec.tasks[e.payload.taskId]?.ownerId);
+ }
+ return null;
+}
+
+/** Tool category from tool.call_started (present on traced runs). */
+export function categoryOfStart(e: AnyEvent): string | null {
+ if (e.type !== 'tool.call_started') return null;
+ const c = (e.payload as { category?: unknown } | null)?.category;
+ return typeof c === 'string' && c ? c : null;
+}
diff --git a/apps/studio/src/state/store.test.ts b/apps/studio/src/state/store.test.ts
new file mode 100644
index 0000000..cbd5a0e
--- /dev/null
+++ b/apps/studio/src/state/store.test.ts
@@ -0,0 +1,302 @@
+import { foldExecution } from '@runloop-world/core';
+import { deliver, uuidFor } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { fakeNow, openFixture, wire } from './test-utils';
+
+const ms = (iso: string) => Date.parse(iso);
+
+describe('studio store: ingest', () => {
+ it('orders and dedupes shuffled, duplicated deliveries', () => {
+ const { store, events } = openFixture();
+ for (const e of deliver(events, 7, { dupRate: 0.3, shuffleWindow: 6 })) store.getState().ingest([wire(e)], 'live');
+ const s = store.getState();
+ expect(s.lastSequence).toBe(events.length);
+ expect(s.events().map((e) => e.eventId)).toEqual(events.map((e) => e.eventId));
+ expect(s.warnings).toEqual([]);
+ expect(s.gap).toBeNull();
+ expect(s.ended).toBe(true);
+ });
+
+ it('holds events after a gap and asks for a backfill after ~2 s', () => {
+ const clock = fakeNow(ms('2026-10-17T09:00:00.000Z'));
+ const { store, events } = openFixture({ now: clock.now });
+ const missing = events[9]!; // sequence 10
+ store.getState().ingest(events.filter((e) => e !== missing).map(wire), 'live');
+ expect(store.getState().lastSequence).toBe(9);
+ expect(store.getState().gap).toEqual({ from: 10, to: 10 });
+ expect(store.getState().gapToBackfill()).toBeNull();
+ clock.advance(2000);
+ expect(store.getState().gapToBackfill()).toEqual({ from: 10, to: 10 });
+ expect(store.getState().gapToBackfill()).toBeNull(); // re-armed
+
+ store.getState().ingest([wire(missing)], 'backlog');
+ expect(store.getState().lastSequence).toBe(events.length);
+ expect(store.getState().gap).toBeNull();
+ });
+
+ it('keeps the first event when two different events claim a sequence', () => {
+ const { store, events } = openFixture();
+ const impostor = { ...events[2]!, eventId: uuidFor(0x77, 1) };
+ store.getState().ingest([wire(events[0]), wire(events[1]), wire(events[2]), wire(impostor)], 'live');
+ const s = store.getState();
+ expect(s.lastSequence).toBe(3);
+ expect(s.events()[2]!.eventId).toBe(events[2]!.eventId);
+ expect(s.warnings).toHaveLength(1);
+ expect(s.warnings[0]).toContain('#3');
+ });
+
+ it('keeps invalid envelopes in raw[] and folds unknown event types', () => {
+ const { store, events } = openFixture();
+ const unknownType = { ...events[1]!, type: 'robot.danced', payload: { style: 'disco' } };
+ store.getState().ingest([wire(events[0]), { hello: 'world' }, wire(unknownType), { hello: 'world' }], 'live');
+ const s = store.getState();
+ expect(s.raw).toHaveLength(1);
+ expect(s.raw[0]!.raw).toEqual({ hello: 'world' });
+ expect(s.lastSequence).toBe(2);
+ expect(s.events()[1]!.type).toBe('robot.danced');
+ expect(s.stateAt(2).exec.anomalies.map((a) => a.code)).toContain('unknown_event');
+ });
+
+ it('folds an invalid envelope that names its sequence as an anomaly instead of stalling', () => {
+ const { store, events } = openFixture();
+ const bad = { ...(wire(events[2]) as object), visibility: 'internal' }; // e.g. from a newer runtime
+ const raws = events.map((e, i) => (i === 2 ? bad : wire(e)));
+ store.getState().ingest(raws, 'live');
+ store.getState().ingest([bad], 'backlog'); // a backfill redelivers it: no duplicate
+ const s = store.getState();
+ expect(s.lastSequence).toBe(events.length);
+ expect(s.gap).toBeNull();
+ expect(s.raw).toEqual([]);
+ expect(s.events()[2]).toMatchObject({ sequence: 3, unknown: true, reason: 'invalid_envelope', eventId: events[2]!.eventId });
+ expect(s.rawEvent(3)).toEqual(bad);
+ expect(s.stateAt(3).exec.anomalies.map((a) => a.code)).toEqual(['unknown_event']);
+ expect(s.ended).toBe(true);
+ });
+
+ it('keeps the raw JSON of each event as received', () => {
+ const { store, events } = openFixture();
+ const withExtra = { ...(wire(events[0]) as object), extraField: 'kept' };
+ store.getState().ingest([withExtra], 'live');
+ expect(store.getState().rawEvent(1)).toEqual(withExtra);
+ });
+});
+
+describe('studio store: cursor', () => {
+ it('new live events never move a replay cursor', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.slice(0, 30).map(wire), 'live');
+ const t = store.getState().timeOfSeq(20);
+ store.getState().seek(t);
+ const before = store.getState();
+ expect(before.cursor).toMatchObject({ mode: 'replay', tMs: t, playing: false });
+ const seq = before.playheadSeq;
+
+ store.getState().ingest(events.slice(30).map(wire), 'live');
+ const after = store.getState();
+ expect(after.cursor).toEqual(before.cursor);
+ expect(after.playheadSeq).toBe(seq);
+ expect(after.newSinceReplay).toBe(events.length - 30);
+
+ after.goLive();
+ expect(store.getState().playheadSeq).toBe(events.length);
+ expect(store.getState().newSinceReplay).toBe(0);
+ });
+
+ it('state at the playhead equals a direct fold, forward and backward', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.map(wire), 'backlog');
+ for (const n of [40, 5, 58, 1, 23]) {
+ store.getState().seekToSeq(n);
+ const s = store.getState();
+ expect(s.playheadSeq).toBe(n);
+ expect(s.stateAt(s.playheadSeq).exec).toEqual(foldExecution(events.slice(0, n)));
+ }
+ });
+
+ it('stops on any event, also one that shares its millisecond with later ones', () => {
+ const { store, events } = openFixture();
+ // Five events in the same millisecond, like a Claude Code hook burst.
+ const t = events[19]!.timestamp;
+ const burst = events.map((e, i) => (i >= 19 && i <= 23 ? { ...e, timestamp: t } : e));
+ store.getState().ingest(burst.map(wire), 'backlog');
+ for (const n of [20, 21, 22, 23, 24]) {
+ store.getState().seekToSeq(n);
+ expect(store.getState().playheadSeq).toBe(n);
+ }
+ // Stepping visits each of them in turn.
+ store.getState().seekToSeq(19);
+ const seen: number[] = [];
+ for (let i = 0; i < 6; i++) {
+ store.getState().stepNext();
+ seen.push(store.getState().playheadSeq);
+ }
+ expect(seen).toEqual([20, 21, 22, 23, 24, 25]);
+ store.getState().stepPrev();
+ store.getState().stepPrev();
+ expect(store.getState().playheadSeq).toBe(23);
+ });
+
+ it('prev/next move one event at a time and clamp at the ends', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.map(wire), 'backlog');
+ store.getState().seek(0);
+ expect(store.getState().playheadSeq).toBe(1);
+ store.getState().stepPrev();
+ expect(store.getState().cursor).toMatchObject({ tMs: 0, seq: 0 });
+ store.getState().stepPrev();
+ expect(store.getState().playheadSeq).toBe(0);
+ store.getState().stepNext();
+ expect(store.getState().playheadSeq).toBe(1);
+ store.getState().stepNext();
+ expect(store.getState().playheadSeq).toBe(2);
+ expect(store.getState().cursor).toMatchObject({ tMs: store.getState().timeOfSeq(2) });
+ store.getState().seekToSeq(events.length);
+ store.getState().stepNext(); // nothing after the last event
+ expect(store.getState().playheadSeq).toBe(events.length);
+ });
+
+ it('late live events never change a paused replay, even when their source time is earlier', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.slice(0, 15).map(wire), 'live');
+ store.getState().pause();
+ const before = store.getState();
+ expect(before.playheadSeq).toBe(15);
+ // Delivered after the pause, observed before it (an async hook or a spool flush).
+ const late = events.slice(15, 17).map((e) => ({ ...e, timestamp: events[14]!.timestamp }));
+ store.getState().ingest(late.map(wire), 'live');
+ const after = store.getState();
+ expect(after.cursor).toEqual(before.cursor);
+ expect(after.playheadSeq).toBe(15);
+ expect(after.newSinceReplay).toBe(2);
+ expect(after.stateAt(after.playheadSeq).exec.lastSequence).toBe(15);
+ });
+
+ it('getFrame is memoized per sequence', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.map(wire), 'backlog');
+ store.getState().seekToSeq(12);
+ const a = store.getState().getFrame();
+ const b = store.getState().getFrame();
+ expect(a.state).toBe(b.state);
+ expect(a.playheadMs).toBe(store.getState().timeOfSeq(12));
+ expect(a.advancing).toBe(false);
+ });
+});
+
+describe('studio store: live playhead', () => {
+ it('follows estimated server time, corrected for client clock skew', () => {
+ const { events } = openFixture();
+ const skew = 5000; // client clock 5 s ahead of the server
+ const clock = fakeNow(ms(events[0]!.timestamp) + skew);
+ const { store } = openFixture({ now: clock.now });
+ store.getState().ingest([wire(events[0])], 'live');
+ expect(store.getState().playheadMs()).toBe(0);
+ clock.advance(1500);
+ expect(store.getState().playheadMs()).toBe(1500);
+ // A later event arriving "early" (lower latency) can only move the estimate forward.
+ clock.set(ms(events[1]!.timestamp) + skew);
+ store.getState().ingest([wire(events[1])], 'live');
+ expect(store.getState().playheadMs()).toBeGreaterThanOrEqual(store.getState().durationMs);
+ });
+
+ it('a run watched live until it ends settles over time in the world, never trailing the last event', () => {
+ const { events } = openFixture();
+ const clock = fakeNow(0); // client clock far behind the server
+ const { store } = openFixture({ now: clock.now });
+ store.getState().mountWorld();
+ store.getState().ingest(events.map(wire), 'live');
+ const s = store.getState();
+ expect(s.ended).toBe(true);
+ expect(s.settleMs).toBeGreaterThanOrEqual(s.durationMs);
+ expect(s.playheadMs()).toBe(s.durationMs);
+ clock.set(Number.MAX_SAFE_INTEGER / 2);
+ expect(store.getState().playheadMs()).toBe(s.settleMs);
+ expect(store.getState().getFrame().advancing).toBe(false);
+ });
+
+ it('a run that was over when opened (or with no world shown) is at its end at once', () => {
+ const { events } = openFixture();
+ const { store } = openFixture({ now: fakeNow(0).now });
+ const unmount = store.getState().mountWorld();
+ store.getState().ingest(events.map(wire), 'backlog');
+ expect(store.getState().playheadMs()).toBe(store.getState().settleMs);
+ expect(store.getState().getFrame().advancing).toBe(false);
+ unmount();
+ const { store: live } = openFixture({ now: fakeNow(0).now });
+ live.getState().ingest(events.map(wire), 'live'); // no world mounted
+ expect(live.getState().playheadMs()).toBe(live.getState().settleMs);
+ });
+});
+
+describe('studio store: playback', () => {
+ it('plays a finished run to its end and stops; play again restarts from 0', () => {
+ const { store, events } = openFixture();
+ store.getState().ingest(events.map(wire), 'backlog');
+ store.getState().seek(0);
+ store.getState().setSpeed(4);
+ store.getState().play();
+ for (let i = 0; i < 100 && store.getState().cursor.mode === 'replay'; i++) {
+ store.getState().advance(1000);
+ if (!(store.getState().cursor as { playing: boolean }).playing) break;
+ }
+ const s = store.getState();
+ expect(s.cursor).toMatchObject({ mode: 'replay', playing: false, speed: 4, tMs: s.settleMs });
+ expect(s.playheadSeq).toBe(events.length);
+ s.play();
+ expect(store.getState().cursor).toMatchObject({ mode: 'replay', playing: true, tMs: 0 });
+ });
+
+ it('ends the scrubber of a running run at the live playhead, not at predicted arrivals', () => {
+ const { events } = openFixture();
+ const clock = fakeNow(ms(events[20]!.timestamp));
+ const { store } = openFixture({ now: clock.now });
+ store.getState().ingest(events.slice(0, 21).map(wire), 'live');
+ const s = store.getState();
+ expect(s.ended).toBe(false);
+ expect(s.settleMs).toBeGreaterThan(s.playheadMs()); // characters are still walking
+ expect(s.endMs()).toBe(s.playheadMs());
+ s.seek(1e9);
+ expect(store.getState().cursor).toMatchObject({ mode: 'replay', tMs: s.durationMs, seq: 21 });
+ expect(store.getState().playheadSeq).toBe(21);
+ });
+
+ it('hands over to live when playback catches up with a running run', () => {
+ const { events } = openFixture();
+ const clock = fakeNow(ms(events[29]!.timestamp));
+ const { store } = openFixture({ now: clock.now });
+ store.getState().ingest(events.slice(0, 30).map(wire), 'live');
+ store.getState().seekToSeq(25);
+ store.getState().play();
+ for (let i = 0; i < 20 && store.getState().cursor.mode === 'replay'; i++) store.getState().advance(1000);
+ expect(store.getState().cursor).toEqual({ mode: 'live' });
+ });
+});
+
+describe('studio store: effects', () => {
+ it('fire for live arrivals and playback, never for scrubs or replay arrivals', () => {
+ const { store, events } = openFixture();
+ const fired: number[][] = [];
+ store.getState().subscribeEffects((evs) => fired.push(evs.map((e) => e.sequence)));
+
+ store.getState().ingest(events.slice(0, 20).map(wire), 'backlog');
+ expect(fired).toEqual([]); // backlog
+ store.getState().ingest([wire(events[20])], 'live');
+ expect(fired).toEqual([[21]]);
+
+ store.getState().seekToSeq(5);
+ expect(fired).toHaveLength(1); // scrub
+ store.getState().ingest([wire(events[21])], 'live');
+ expect(fired).toHaveLength(1); // arrival while in replay
+
+ store.getState().play();
+ const start = store.getState().playheadSeq;
+ for (let i = 0; i < 40; i++) store.getState().advance(100);
+ const seen = fired.slice(1).flat();
+ expect(seen.length).toBeGreaterThan(0);
+ expect(seen).toEqual(seen.map((_, i) => start + 1 + i)); // contiguous, in order, no repeats
+
+ store.getState().seekToSeq(1);
+ store.getState().seekToSeq(22); // big jump forward: no bulk effects
+ expect(fired.slice(1).flat()).toEqual(seen);
+ });
+});
diff --git a/apps/studio/src/state/store.ts b/apps/studio/src/state/store.ts
new file mode 100644
index 0000000..331e94e
--- /dev/null
+++ b/apps/studio/src/state/store.ts
@@ -0,0 +1,753 @@
+import {
+ invalidEnvelopeEvent,
+ parseEvent,
+ SequenceBuffer,
+ type AnyEvent,
+ type RunDetailResponse,
+ type RunStatus,
+ type RunSummary,
+} from '@runloop-world/events';
+import { initialRunState, ReplayIndex, type RunState } from '@runloop-world/replay';
+import { arrivalTick, loadWorld, type CompiledWorld } from '@runloop-world/world-engine';
+import { createStore, type StoreApi } from 'zustand/vanilla';
+import { setApiBaseOverride } from '../lib/config';
+import type { CameraMode, Quality, SceneFrame } from '../scene/types';
+import { EMPTY_FILTER, type TraceFilter } from './filters';
+import { EMPTY_ONBOARDING, type OnboardingProgress } from './onboarding';
+import { advancePlayback } from './playback';
+import type { MotionPref, ThemePref } from './prefs';
+import { agentOfEvent, latestEventOf, type InspectorTab } from './selection';
+
+export type Speed = 0.5 | 1 | 2 | 4;
+export const SPEEDS: readonly Speed[] = [0.5, 1, 2, 4];
+
+/**
+ * Where the playhead is. Live follows the run in (estimated) server time.
+ * Replay is a time since run start plus the exact event shown (`seq`): events
+ * can share a millisecond, so the event is kept, not derived from the time.
+ * Only the user (or playback) moves a replay cursor; events arriving later
+ * never change what it shows.
+ */
+export type Cursor = { mode: 'live' } | { mode: 'replay'; tMs: number; seq: number; playing: boolean; speed: Speed };
+
+export type Tab = 'timeline' | 'logs' | 'metrics' | 'state' | 'metadata';
+export type ConnStatus = 'idle' | 'loading' | 'streaming' | 'reconnecting' | 'complete' | 'error';
+export type RuntimeStatus = 'unknown' | 'online' | 'offline';
+/** Live World main view: the 3D world, or the 2D Network view (agent graph or agents table). */
+export type WorldView = 'world' | 'graph';
+
+/** Claude Code ingest as reported by /api/health (absent on runtimes without the tracer). */
+export interface IngestStatus {
+ claudeCode: { protocol: number } | null;
+}
+
+/** A deep-link target waiting for its events to arrive. */
+export interface PendingFocus {
+ /** The event the inspector shows (the playhead moves there unless `at` says otherwise). */
+ seq: number | null;
+ /** The agent whose card the inspector shows (when no event is named). */
+ agentId: string | null;
+ /** The paused playhead (event sequence); null keeps the cursor where it is (live). */
+ at?: number | null;
+}
+
+/** Something received that is not a valid event envelope (kept so the Logs tab can show it). */
+export interface RawEntry {
+ id: number;
+ receivedAt: number;
+ raw: unknown;
+ error: string;
+ sequence: number | null;
+}
+
+export interface StudioState {
+ // ── runtime + runs list ───────────────────────────────────────────
+ runtime: {
+ status: RuntimeStatus;
+ version: string | null;
+ activeRunId: string | null;
+ /** null = this runtime does not report ingest (older runtime). */
+ ingest: IngestStatus | null;
+ tracedRunIds: string[];
+ };
+ runs: RunSummary[];
+
+ // ── the open run ─────────────────────────────────────────────────
+ runId: string | null;
+ run: RunSummary | null;
+ world: CompiledWorld | null;
+ loadError: string | null;
+ conn: ConnStatus;
+ /** Data-integrity problems worth showing (sequence conflicts, fold errors). */
+ warnings: string[];
+ /** Highest contiguous sequence received (the replay index holds 1..lastSequence). */
+ lastSequence: number;
+ /** Time of the last event since run start. */
+ durationMs: number;
+ /** End of the scrubbable range once characters finished walking (≥ durationMs). */
+ settleMs: number;
+ /** The run reached a terminal status. */
+ ended: boolean;
+ raw: RawEntry[];
+ /** Missing sequences blocking release, if any. */
+ gap: { from: number; to: number } | null;
+
+ // ── playhead ─────────────────────────────────────────────────────
+ cursor: Cursor;
+ /** Sequence at the playhead (live → lastSequence; replay → seqAtTime(tMs)). */
+ playheadSeq: number;
+ /** Events that arrived while the cursor was in replay. */
+ newSinceReplay: number;
+ /** Replay speed preference (also used when a replay starts from live). */
+ speed: Speed;
+
+ // ── selection (one state shared by world, timeline, logs and inspector) ──
+ /** Selected event (sequence). */
+ selectedSeq: number | null;
+ selectedAgentId: string | null;
+ selectedStationId: string | null;
+ inspectorTab: InspectorTab;
+ /** Deep-link target applied once its events have arrived. */
+ pendingFocus: PendingFocus | null;
+
+ // ── view options ─────────────────────────────────────────────────
+ tab: Tab;
+ worldView: WorldView;
+ filter: TraceFilter;
+ themePref: ThemePref;
+ motionPref: MotionPref;
+ /** Viewer's runtime URL override (null = build default). */
+ apiBaseOverride: string | null;
+ /** Bumped to reconnect the open run (retry after an error). */
+ connectNonce: number;
+ cameraMode: CameraMode;
+ follow: boolean;
+ quality: Quality;
+ reducedMotion: boolean;
+ /** Artifact open in the viewer. */
+ viewArtifactId: string | null;
+ /** What this viewer has done (Overview checklist; kept in this browser only). */
+ onboarding: OnboardingProgress;
+
+ // ── actions ──────────────────────────────────────────────────────
+ setRuntime(r: Partial): void;
+ setRuns(runs: RunSummary[]): void;
+ /** Forget the current run and start loading `runId` (null = nothing open). */
+ openRun(runId: string | null): void;
+ loadRun(detail: RunDetailResponse): void;
+ setLoadError(message: string): void;
+ setConn(conn: ConnStatus): void;
+ /** Parse and add raw events. `live` = pushed by the stream (may fire effects). */
+ ingest(raws: readonly unknown[], source: 'backlog' | 'live'): void;
+ /** The server said the run is terminal (SSE `end`). */
+ markEnded(status: RunStatus, lastSequence: number): void;
+ /** Gap that has stayed open long enough to backfill (re-armed after each call). */
+ gapToBackfill(): { from: number; to: number } | null;
+
+ goLive(): void;
+ seek(tMs: number): void;
+ seekToSeq(seq: number): void;
+ play(): void;
+ pause(): void;
+ togglePlay(): void;
+ stepNext(): void;
+ stepPrev(): void;
+ setSpeed(speed: Speed): void;
+ /** Advance replay playback by wall-clock `dtMs` (called every animation frame). */
+ advance(dtMs: number): void;
+
+ /**
+ * Select an event: its agent and that agent's station follow, and the
+ * inspector shows the event. `seek` also moves the playhead to it.
+ */
+ selectEvent(seq: number | null, opts?: { seek?: boolean }): void;
+ /** Select an agent: its station follows, its latest event at the playhead is highlighted, the inspector shows its card. */
+ selectAgent(agentId: string | null): void;
+ selectStation(stationId: string | null): void;
+ clearSelection(): void;
+ setInspectorTab(tab: InspectorTab): void;
+ /** Focus a deep-link target now, or as soon as its events arrive. */
+ focusDeepLink(target: PendingFocus): void;
+ cycleAgent(dir: 1 | -1): void;
+ setTab(tab: Tab): void;
+ setWorldView(view: WorldView): void;
+ setFilter(filter: TraceFilter): void;
+ setThemePref(theme: ThemePref): void;
+ setMotionPref(motion: MotionPref): void;
+ /** Point the Studio at another runtime URL (null = build default); starts over with that runtime. */
+ setApiBase(url: string | null): void;
+ retryConnection(): void;
+ setCameraMode(mode: CameraMode): void;
+ toggleFollow(): void;
+ setQuality(q: Quality): void;
+ setReducedMotion(on: boolean): void;
+ openArtifact(artifactId: string | null): void;
+ setOnboarding(patch: Partial): void;
+
+ /** A world view mounted (characters walk; a finished run settles over time). Returns the unmount. */
+ mountWorld(): () => void;
+
+ // ── reads (non-reactive; pair them with lastSequence / playheadSeq) ─
+ events(): readonly AnyEvent[];
+ /** Event JSON exactly as received, by sequence. */
+ rawEvent(seq: number): unknown;
+ stateAt(seq: number): RunState;
+ timeOfSeq(seq: number): number;
+ playheadMs(): number;
+ /** Right end of the scrubber right now. */
+ endMs(): number;
+ getFrame(): SceneFrame;
+ subscribeEffects(fn: (events: readonly AnyEvent[]) => void): () => void;
+}
+
+export type StudioStore = StoreApi;
+
+export interface StudioStoreOptions {
+ /** Wall clock (ms since epoch); injectable for tests. */
+ now?: () => number;
+ /** How long a gap may stay open before it is backfilled. */
+ gapBackfillMs?: number;
+}
+
+const TERMINAL: ReadonlySet = new Set(['succeeded', 'failed', 'stopped', 'interrupted']);
+/** Larger forward steps than this are treated as jumps (no effects). */
+const MAX_EFFECT_BATCH = 12;
+/** Cap on how long characters may keep walking after the last event. */
+const MAX_SETTLE_MS = 15_000;
+
+export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore {
+ const now = opts.now ?? Date.now;
+ const gapBackfillMs = opts.gapBackfillMs ?? 2000;
+
+ // Non-reactive internals, reset per run. React re-renders on lastSequence/playheadSeq instead.
+ let buffer = new SequenceBuffer();
+ let index: ReplayIndex | null = null;
+ let held: AnyEvent[] = []; // released before the world was known
+ /** Event JSON as received, by eventId (first delivery wins, like the buffer). */
+ let rawById = new Map();
+ let rawSeen = new Set();
+ let rawId = 0;
+ let conflictsSeen = 0;
+ /** min(clientNow − eventTimestamp): client clock minus server clock, plus the smallest latency seen. */
+ let clockOffsetMs: number | null = null;
+ /** Latest valid event timestamp seen (stand-in time for invalid envelopes). */
+ let lastTimestamp: string | null = null;
+ let gapSince: number | null = null;
+ let frameMemo: { seq: number; state: RunState } | null = null;
+ /** Live events arrived before the run ended (else it was already over when opened: no settle animation). */
+ let sawLive = false;
+ /** World views mounted; without one there is nothing to settle. */
+ let worldsMounted = 0;
+ const effectListeners = new Set<(events: readonly AnyEvent[]) => void>();
+
+ const emitEffects = (events: readonly AnyEvent[]) => {
+ if (events.length === 0 || events.length > MAX_EFFECT_BATCH) return;
+ for (const fn of effectListeners) {
+ try {
+ fn(events);
+ } catch (err) {
+ console.error('[studio] effect listener failed', err);
+ }
+ }
+ };
+
+ const resetInternals = () => {
+ buffer = new SequenceBuffer();
+ index = null;
+ held = [];
+ rawById = new Map();
+ rawSeen = new Set();
+ conflictsSeen = 0;
+ clockOffsetMs = null;
+ lastTimestamp = null;
+ gapSince = null;
+ frameMemo = null;
+ sawLive = false;
+ };
+
+ /**
+ * When the characters finish their last walk (ms since run start, ≥ durationMs).
+ * A finished run's playhead stops here instead of at the last event, so
+ * characters are not frozen mid-step at the end of a replay.
+ */
+ const computeSettleMs = (world: CompiledWorld): number => {
+ if (!index) return 0;
+ const w = index.latest().world;
+ let maxTick = 0;
+ for (const id of w.characterOrder) {
+ const c = w.characters[id];
+ if (c) maxTick = Math.max(maxTick, arrivalTick(c.motion, world.def.ticksPerCell));
+ }
+ const duration = index.durationMs;
+ return Math.min(duration + MAX_SETTLE_MS, Math.max(duration, (maxTick / world.def.tickHz) * 1000));
+ };
+
+ /**
+ * Live playhead: estimated server time since run start (client clock corrected
+ * by the offset seen on arrivals), never before the last event, and stopping
+ * once a finished run's characters have settled.
+ */
+ const livePlayheadMs = (s: StudioState): number => {
+ if (!index || index.runStartMs === null) return 0;
+ // A run that was over when opened, or with no world on screen, shows its end at once.
+ if (s.ended && (!sawLive || worldsMounted === 0)) return s.settleMs;
+ const serverNow = now() - (clockOffsetMs ?? 0);
+ const t = Math.max(index.durationMs, serverNow - index.runStartMs);
+ return s.ended ? Math.min(t, s.settleMs) : t;
+ };
+
+ /**
+ * Right end of the scrubber. A finished run ends once its characters settle;
+ * a running run ends at its live playhead (never at a predicted future).
+ */
+ const endMsOf = (s: StudioState): number => (s.ended ? s.settleMs : livePlayheadMs(s));
+
+ const seqAt = (cursor: Cursor): number => {
+ if (!index) return 0;
+ return cursor.mode === 'live' ? index.lastSequence : Math.min(cursor.seq, index.lastSequence);
+ };
+
+ /** Character's station after event `seq` (default: the playhead), for selection. */
+ const stationOf = (agentId: string | null, seq?: number): string | null => {
+ if (!agentId || !index) return null;
+ return index.stateAt(seq ?? store.getState().playheadSeq).world.characters[agentId]?.stationId ?? null;
+ };
+
+ /** Apply a deep-link target whose events have arrived; drop it once it can never apply. */
+ const applyPendingFocus = () => {
+ const s = store.getState();
+ const p = s.pendingFocus;
+ if (!p || !index) return;
+ const at = p.at ?? null;
+ const seqReady = (p.seq === null || index.lastSequence >= p.seq) && (at === null || index.lastSequence >= at);
+ const agentReady = p.agentId === null || index.latest().exec.agents[p.agentId] !== undefined;
+ if (!seqReady || !agentReady) {
+ if (s.ended) store.setState({ pendingFocus: null }); // the run is complete: what is missing never comes
+ return;
+ }
+ store.setState({ pendingFocus: null });
+ // The playhead first: an explicit one, else the named event. With neither, a live run stays live.
+ const playhead = at ?? p.seq;
+ if (playhead !== null) s.seekToSeq(playhead);
+ // Then what the inspector shows: the event (its own agent), else the agent's card.
+ if (p.seq !== null) s.selectEvent(p.seq);
+ else if (p.agentId !== null) s.selectAgent(p.agentId);
+ };
+
+ /** A replay cursor at `tMs`, showing event `seq` (default: the last event at that time). */
+ const toReplay = (s: StudioState, tMs: number, opts: { playing?: boolean; seq?: number } = {}): Cursor => {
+ const prev = s.cursor.mode === 'replay' ? s.cursor : null;
+ const clamped = Math.max(0, Math.min(endMsOf(s), tMs));
+ const seq = opts.seq ?? (index ? index.seqAtTime(clamped) : 0);
+ return { mode: 'replay', tMs: clamped, seq, playing: opts.playing ?? prev?.playing ?? false, speed: s.speed };
+ };
+
+ /** Pause on event `seq` exactly (0 = before the first event). */
+ const cursorAtSeq = (s: StudioState, seq: number): Cursor | null => {
+ if (!index) return null;
+ const n = Math.max(0, Math.min(index.lastSequence, Math.floor(seq)));
+ return toReplay(s, index.timeOfSeq(n), { playing: false, seq: n });
+ };
+
+ const setCursor = (cursor: Cursor) => {
+ const s = store.getState();
+ const leavingLive = s.cursor.mode === 'live' && cursor.mode === 'replay';
+ store.setState({
+ cursor,
+ playheadSeq: seqAt(cursor),
+ ...(cursor.mode === 'live' || leavingLive ? { newSinceReplay: 0 } : {}),
+ });
+ };
+
+ const appendReleased = (released: AnyEvent[]) => {
+ if (released.length === 0) return;
+ if (!index) {
+ held.push(...released);
+ return;
+ }
+ try {
+ index.append(released);
+ } catch (err) {
+ store.setState((s) => ({ warnings: [...s.warnings, `Could not fold events: ${String(err)}`] }));
+ }
+ };
+
+ const store = createStore()((set, get) => ({
+ runtime: { status: 'unknown', version: null, activeRunId: null, ingest: null, tracedRunIds: [] },
+ runs: [],
+
+ runId: null,
+ run: null,
+ world: null,
+ loadError: null,
+ conn: 'idle',
+ warnings: [],
+ lastSequence: 0,
+ durationMs: 0,
+ settleMs: 0,
+ ended: false,
+ raw: [],
+ gap: null,
+
+ cursor: { mode: 'live' },
+ playheadSeq: 0,
+ newSinceReplay: 0,
+ speed: 1,
+
+ selectedSeq: null,
+ selectedAgentId: null,
+ selectedStationId: null,
+ inspectorTab: 'run',
+ pendingFocus: null,
+ tab: 'timeline',
+ worldView: 'world',
+ filter: EMPTY_FILTER,
+ themePref: 'system',
+ motionPref: 'system',
+ apiBaseOverride: null,
+ connectNonce: 0,
+ cameraMode: 'iso',
+ follow: false,
+ quality: 'high',
+ reducedMotion: false,
+ viewArtifactId: null,
+ onboarding: EMPTY_ONBOARDING,
+
+ setRuntime: (r) => set((s) => ({ runtime: { ...s.runtime, ...r } })),
+
+ setRuns: (runs) => {
+ const s = get();
+ const mine = s.runId ? runs.find((r) => r.runId === s.runId) : undefined;
+ const ended = s.ended || (mine !== undefined && TERMINAL.has(mine.status) && s.lastSequence >= mine.lastSequence);
+ set({ runs, ...(mine ? { run: mine, ended } : {}) });
+ },
+
+ openRun: (runId) => {
+ resetInternals();
+ set({
+ runId,
+ run: runId ? (get().runs.find((r) => r.runId === runId) ?? null) : null,
+ world: null,
+ loadError: null,
+ conn: runId ? 'loading' : 'idle',
+ warnings: [],
+ lastSequence: 0,
+ durationMs: 0,
+ settleMs: 0,
+ ended: false,
+ raw: [],
+ gap: null,
+ cursor: { mode: 'live' },
+ playheadSeq: 0,
+ newSinceReplay: 0,
+ selectedSeq: null,
+ selectedAgentId: null,
+ selectedStationId: null,
+ inspectorTab: 'run',
+ pendingFocus: null,
+ viewArtifactId: null,
+ });
+ },
+
+ loadRun: (detail) => {
+ if (detail.run.runId !== get().runId) return;
+ if (index) {
+ // Reconnect to the run already open: keep the events we have.
+ set({ run: detail.run, loadError: null });
+ return;
+ }
+ let world: CompiledWorld;
+ try {
+ world = loadWorld(detail.world);
+ } catch (err) {
+ set({ run: detail.run, loadError: `Invalid world definition: ${err instanceof Error ? err.message : String(err)}` });
+ return;
+ }
+ index = new ReplayIndex(world);
+ set({ run: detail.run, world, loadError: null });
+ const pending = held;
+ held = [];
+ appendReleased(pending);
+ syncDerived();
+ },
+
+ setLoadError: (message) => set({ loadError: message, conn: 'error' }),
+ setConn: (conn) => set({ conn }),
+
+ ingest: (raws, source) => {
+ const s = get();
+ const t = now();
+ const released: AnyEvent[] = [];
+ const newRaw: RawEntry[] = [];
+ for (const raw of raws) {
+ const parsed = parseEvent(raw);
+ if (!parsed.ok) {
+ // Still names its sequence: fold a placeholder (an anomaly) so later events are not held forever.
+ const fallback = { runId: s.runId ?? '', timestamp: lastTimestamp ?? new Date(t).toISOString() };
+ const placeholder = invalidEnvelopeEvent(raw, parsed.error, fallback);
+ if (placeholder && (!s.runId || placeholder.runId === s.runId)) {
+ if (!rawById.has(placeholder.eventId)) rawById.set(placeholder.eventId, raw);
+ released.push(...buffer.push(placeholder));
+ continue;
+ }
+ const key = safeJson(raw);
+ if (rawSeen.has(key)) continue;
+ rawSeen.add(key);
+ const seq = (raw as { sequence?: unknown } | null)?.sequence;
+ newRaw.push({ id: ++rawId, receivedAt: t, raw, error: parsed.error, sequence: typeof seq === 'number' ? seq : null });
+ continue;
+ }
+ const e = parsed.event;
+ if (s.runId && e.runId !== s.runId) continue; // stale delivery from a previous run
+ const ts = Date.parse(e.timestamp);
+ if (Number.isFinite(ts)) {
+ clockOffsetMs = Math.min(clockOffsetMs ?? Infinity, t - ts);
+ if (lastTimestamp === null || ts > Date.parse(lastTimestamp)) lastTimestamp = e.timestamp;
+ }
+ if (!rawById.has(e.eventId)) rawById.set(e.eventId, raw);
+ released.push(...buffer.push(e));
+ }
+ appendReleased(released);
+
+ const conflicts = buffer.conflicts.slice(conflictsSeen).map(
+ (c) => `Sequence #${c.sequence}: kept event ${c.keptEventId}, dropped conflicting ${c.droppedEventId}`,
+ );
+ conflictsSeen = buffer.conflicts.length;
+ const missing = buffer.missing();
+ if (missing === null) gapSince = null;
+ else gapSince ??= t;
+ // Keep references stable when nothing changed (fewer re-renders).
+ const prevGap = get().gap;
+ const gap = missing && prevGap && missing.from === prevGap.from && missing.to === prevGap.to ? prevGap : missing;
+
+ const wasReplay = get().cursor.mode === 'replay';
+ if (source === 'live' && released.length > 0 && !get().ended) sawLive = true;
+ set({
+ gap,
+ ...(conflicts.length ? { warnings: [...get().warnings, ...conflicts] } : {}),
+ ...(newRaw.length ? { raw: [...get().raw, ...newRaw] } : {}),
+ ...(wasReplay && source === 'live' ? { newSinceReplay: get().newSinceReplay + released.length } : {}),
+ });
+ if (released.length) syncDerived();
+ if (source === 'live' && !wasReplay && index) emitEffects(released);
+ },
+
+ markEnded: (status, lastSequence) => {
+ const s = get();
+ set({
+ ended: true,
+ ...(s.run ? { run: { ...s.run, status, lastSequence: Math.max(s.run.lastSequence, lastSequence) } } : {}),
+ });
+ syncDerived();
+ },
+
+ gapToBackfill: () => {
+ const gap = get().gap;
+ if (!gap || gapSince === null || now() - gapSince < gapBackfillMs) return null;
+ gapSince = now(); // re-arm: try again after another interval if it is still open
+ return gap;
+ },
+
+ goLive: () => setCursor({ mode: 'live' }),
+
+ seek: (tMs) => setCursor(toReplay(get(), tMs)),
+
+ seekToSeq: (seq) => {
+ const c = cursorAtSeq(get(), seq);
+ if (c) setCursor(c);
+ },
+
+ play: () => {
+ const s = get();
+ const end = endMsOf(s);
+ const t = s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s);
+ // At the end of a finished run, play restarts from the beginning.
+ if (t >= end - 1 && s.ended) setCursor(toReplay(s, 0, { playing: true }));
+ else setCursor(toReplay(s, t, { playing: true, seq: s.playheadSeq }));
+ },
+
+ pause: () => {
+ const s = get();
+ setCursor(toReplay(s, s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s), { playing: false, seq: s.playheadSeq }));
+ },
+
+ togglePlay: () => {
+ const s = get();
+ // Live counts as playing, except at the very end of a finished run.
+ const playing = s.cursor.mode === 'live' ? !(s.ended && livePlayheadMs(s) >= s.settleMs) : s.cursor.playing;
+ if (playing) s.pause();
+ else s.play();
+ },
+
+ // One event at a time (events can share a millisecond).
+ stepNext: () => {
+ if (!index) return;
+ const s = get();
+ const before = s.playheadSeq;
+ if (before >= index.lastSequence) return;
+ setCursor(cursorAtSeq(s, before + 1)!);
+ emitEffects(index.events.slice(before, before + 1));
+ },
+
+ stepPrev: () => {
+ if (!index) return;
+ const s = get();
+ setCursor(cursorAtSeq(s, s.playheadSeq - 1)!);
+ },
+
+ setSpeed: (speed) => {
+ const s = get();
+ set({ speed, ...(s.cursor.mode === 'replay' ? { cursor: { ...s.cursor, speed } } : {}) });
+ },
+
+ advance: (dtMs) => {
+ const s = get();
+ if (s.cursor.mode !== 'replay' || !s.cursor.playing || !index) return;
+ const end = endMsOf(s);
+ const next = advancePlayback(s.cursor, dtMs, end);
+ const before = s.playheadSeq;
+ // Playback only moves forward, from the event shown (which may be early in its millisecond).
+ const seq = Math.max(before, index.seqAtTime(next.tMs));
+ // Caught up with a run that is still going: hand over to live.
+ setCursor(!next.playing && !s.ended ? { mode: 'live' } : { ...next, seq });
+ const after = get().playheadSeq;
+ if (after > before) emitEffects(index.events.slice(before, after));
+ },
+
+ selectEvent: (seq, opts) => {
+ if (seq === null) {
+ set({ selectedSeq: null, inspectorTab: get().selectedAgentId ? 'agent' : 'run' });
+ return;
+ }
+ if (!index || seq < 1 || seq > index.lastSequence) return;
+ const e = index.events[seq - 1]!;
+ const agentId = agentOfEvent(e, index.latest().exec);
+ set({ selectedSeq: seq, selectedAgentId: agentId, selectedStationId: stationOf(agentId, seq), inspectorTab: 'event' });
+ if (opts?.seek) get().seekToSeq(seq);
+ },
+
+ selectAgent: (agentId) => {
+ if (agentId === null) {
+ set((s) => ({ selectedAgentId: null, selectedStationId: null, inspectorTab: s.selectedSeq !== null ? 'event' : 'run' }));
+ return;
+ }
+ const s = get();
+ const latest = index ? latestEventOf(index.events, index.latest().exec, agentId, s.playheadSeq) : null;
+ set({ selectedAgentId: agentId, selectedStationId: stationOf(agentId), selectedSeq: latest, inspectorTab: 'agent' });
+ },
+
+ selectStation: (stationId) => set({ selectedStationId: stationId }),
+
+ clearSelection: () => set({ selectedSeq: null, selectedAgentId: null, selectedStationId: null, inspectorTab: 'run' }),
+
+ setInspectorTab: (inspectorTab) => set({ inspectorTab }),
+
+ focusDeepLink: (target) => {
+ if (target.seq === null && target.agentId === null) return;
+ set({ pendingFocus: target });
+ applyPendingFocus();
+ },
+
+ cycleAgent: (dir) => {
+ if (!index) return;
+ const exec = index.latest().exec;
+ const ids = exec.agentOrder.filter((id) => exec.agents[id]?.embodied);
+ if (ids.length === 0) return;
+ const cur = get().selectedAgentId;
+ const i = cur ? ids.indexOf(cur) : -1;
+ const next = ids[(i + dir + ids.length) % ids.length] ?? ids[0]!;
+ get().selectAgent(i === -1 && dir === -1 ? ids[ids.length - 1]! : next);
+ },
+
+ setTab: (tab) => set({ tab }),
+ setWorldView: (worldView) => set({ worldView }),
+ setFilter: (filter) => set({ filter }),
+ setThemePref: (themePref) => set({ themePref }),
+ setMotionPref: (motionPref) => set({ motionPref }),
+ setApiBase: (url) => {
+ setApiBaseOverride(url);
+ get().openRun(null);
+ set({ apiBaseOverride: url, runs: [], runtime: { status: 'unknown', version: null, activeRunId: null, ingest: null, tracedRunIds: [] } });
+ },
+ retryConnection: () => {
+ const runId = get().runId;
+ get().openRun(runId);
+ set((s) => ({ connectNonce: s.connectNonce + 1 }));
+ },
+ setCameraMode: (cameraMode) => set({ cameraMode }),
+ toggleFollow: () => set((s) => ({ follow: !s.follow })),
+ setQuality: (quality) => set({ quality }),
+ setReducedMotion: (reducedMotion) => set({ reducedMotion }),
+ openArtifact: (viewArtifactId) => set({ viewArtifactId }),
+ setOnboarding: (patch) => {
+ const cur = get().onboarding;
+ if (Object.entries(patch).every(([k, v]) => cur[k as keyof OnboardingProgress] === v)) return;
+ set({ onboarding: { ...cur, ...patch } });
+ },
+
+ events: () => index?.events ?? [],
+ rawEvent: (seq) => {
+ const e = index?.events[seq - 1];
+ return e ? rawById.get(e.eventId) : undefined;
+ },
+ stateAt: (seq) => index?.stateAt(seq) ?? initialRunState(),
+ timeOfSeq: (seq) => index?.timeOfSeq(seq) ?? 0,
+ playheadMs: () => {
+ const s = get();
+ return s.cursor.mode === 'live' ? livePlayheadMs(s) : s.cursor.tMs;
+ },
+ endMs: () => endMsOf(get()),
+
+ getFrame: () => {
+ const s = get();
+ const seq = s.playheadSeq;
+ if (!frameMemo || frameMemo.seq !== seq || !index) {
+ frameMemo = { seq, state: index ? index.stateAt(seq) : initialRunState() };
+ }
+ const playheadMs = s.cursor.mode === 'live' ? livePlayheadMs(s) : s.cursor.tMs;
+ const advancing = s.cursor.mode === 'live' ? !s.ended || playheadMs < s.settleMs : s.cursor.playing;
+ return { state: frameMemo.state, tick: index ? index.tickAtMs(playheadMs) : 0, playheadMs, advancing };
+ },
+
+ mountWorld: () => {
+ worldsMounted += 1;
+ let mounted = true;
+ return () => {
+ if (mounted) worldsMounted -= 1;
+ mounted = false;
+ };
+ },
+
+ subscribeEffects: (fn) => {
+ effectListeners.add(fn);
+ return () => {
+ effectListeners.delete(fn);
+ };
+ },
+ }));
+
+ /** Recompute the derived fields after events or the end status changed. */
+ function syncDerived() {
+ const s = store.getState();
+ if (!index || !s.world) return;
+ const terminal = TERMINAL.has(index.latest().exec.run.status);
+ store.setState({
+ lastSequence: index.lastSequence,
+ durationMs: index.durationMs,
+ settleMs: computeSettleMs(s.world),
+ ended: s.ended || terminal,
+ playheadSeq: seqAt(s.cursor),
+ });
+ applyPendingFocus();
+ }
+
+ return store;
+}
+
+function safeJson(v: unknown): string {
+ try {
+ return JSON.stringify(v) ?? String(v);
+ } catch {
+ return String(v);
+ }
+}
diff --git a/apps/studio/src/state/test-utils.ts b/apps/studio/src/state/test-utils.ts
new file mode 100644
index 0000000..57387b8
--- /dev/null
+++ b/apps/studio/src/state/test-utils.ts
@@ -0,0 +1,122 @@
+// Test helpers (node only: reads the world from disk). Never import from browser code.
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import type { RunDetailResponse, RunEvent, RunSummary } from '@runloop-world/events';
+import { codingMissionFixture, runBuilder } from '@runloop-world/testkit';
+import { createStudioStore, type StudioStore } from './store';
+
+const WORLD_FILE = path.join(import.meta.dirname, '../../../../worlds/research-campus/world.json');
+
+export function worldJson(): unknown {
+ return JSON.parse(readFileSync(WORLD_FILE, 'utf8')) as unknown;
+}
+
+export function detailFor(events: readonly RunEvent[], status: RunSummary['status'] = 'running'): RunDetailResponse {
+ const first = events[0]!;
+ return {
+ run: {
+ runId: first.runId,
+ title: 'Fixture run',
+ missionId: 'coding-mission',
+ worldId: 'research-campus',
+ status,
+ createdAt: first.timestamp,
+ endedAt: null,
+ lastSequence: events.length,
+ adapterLabel: 'Scripted adapter',
+ paused: false,
+ },
+ world: worldJson(),
+ };
+}
+
+/**
+ * A small traced Claude Code run for tests, hand-built in the shape of the
+ * runtime's s1/s2 golden traces (packages/runtime/test/fixtures/claude-code):
+ * a failing then passing test run, an Explore subagent with reported usage,
+ * a denied permission Runloop inferred, and a compaction marker. Test data
+ * only; it never reaches a runtime.
+ */
+export function tracedFixture(): RunEvent[] {
+ const b = runBuilder({ namespace: 0x7c, startIso: '2026-10-17T10:00:00.000Z' });
+ const rep = { provenance: 'reported' as const };
+ b.add(
+ 'run.started',
+ {
+ title: 'Claude Code · demo',
+ missionId: 'claude-code',
+ worldId: 'research-campus',
+ adapter: { id: 'claude-code', kind: 'external', label: 'Claude Code (traced via hooks)' },
+ mode: 'traced',
+ source: { kind: 'claude-code', sessionId: '448e50c5-fddf-484e-a28a-4984322713a9', segment: 1, projectLabel: 'demo', model: null, permissionMode: 'default', pluginVersion: '0.1.0' },
+ },
+ rep,
+ );
+ b.add('agent.registered', { agentId: 'user', name: 'You', role: 'user', model: null, tools: [], embodied: false }, rep);
+ b.add('agent.registered', { agentId: 'main', name: 'Claude', role: 'main agent', model: null, tools: [], embodied: true, characterPreset: 'claude' }, rep);
+ b.add('task.created', { taskId: 'turn-1', title: 'Turn 1', kind: 'turn', dependsOn: [] }, { ...rep, actorId: 'user', taskId: 'turn-1' });
+ b.add('task.started', { taskId: 'turn-1', agentId: 'main' }, { ...rep, actorId: 'main', taskId: 'turn-1' });
+ b.add('agent.message_sent', { messageId: 'msg-1', fromAgentId: 'user', to: 'main', text: 'Fix the failing tests.' }, { ...rep, actorId: 'user', visibility: 'sensitive' });
+ // A test run that fails (exit 1, counts as reported), behind a permission that is never answered.
+ b.wait(1000);
+ const perm = b.add('tool.call_started', { callId: 'c-perm', agentId: 'main', taskId: 'turn-1', tool: 'Bash', category: 'deploy', inputSummary: 'git push origin HEAD' }, { ...rep, actorId: 'main', taskId: 'turn-1' });
+ b.add('tool.permission_requested', { permissionId: 'perm-1', agentId: 'main', callId: 'c-perm', action: 'Bash git push origin HEAD', scope: 'default' }, { ...rep, actorId: 'main' });
+ b.wait(500);
+ const t1 = b.add('tool.call_started', { callId: 'c-test1', agentId: 'main', taskId: 'turn-1', tool: 'Bash', category: 'test', inputSummary: 'node --test' }, { ...rep, actorId: 'main', taskId: 'turn-1' });
+ b.wait(400);
+ b.add(
+ 'tool.call_completed',
+ { callId: 'c-test1', status: 'error', durationMs: 374, exitCode: 1, outputSummary: 'Exit code 1 · 12 tests: 10 passed, 2 failed', metrics: { tests: 12, passed: 10, failed: 2 }, failingTests: ['listOpen reflects complete()'], errorType: 'exit_nonzero' },
+ { ...rep, actorId: 'main', taskId: 'turn-1', parentEventId: t1.eventId },
+ );
+ // Delegation: an Agent call starts an Explore subagent, which reads a file and reports usage.
+ b.wait(300);
+ const agentCall = b.add('tool.call_started', { callId: 'c-agent', agentId: 'main', taskId: 'turn-1', tool: 'Agent', category: 'delegate', inputSummary: '→ Explore: "Summarize task-store.js"' }, { ...rep, actorId: 'main', taskId: 'turn-1' });
+ b.add('agent.registered', { agentId: 'sa-1', name: 'Explore #1', role: 'Explore', model: null, tools: [], embodied: true, characterPreset: 'subagent-1' }, rep);
+ b.add('task.created', { taskId: 'delegate-1', title: 'Summarize task-store.js', kind: 'delegated', dependsOn: [] }, { ...rep, actorId: 'main', taskId: 'delegate-1', parentEventId: agentCall.eventId });
+ b.add('task.assigned', { taskId: 'delegate-1', agentId: 'sa-1' }, { ...rep, actorId: 'main', taskId: 'delegate-1' });
+ b.add('task.started', { taskId: 'delegate-1', agentId: 'sa-1' }, { ...rep, actorId: 'sa-1', taskId: 'delegate-1' });
+ b.wait(200);
+ const read = b.add('tool.call_started', { callId: 'c-read', agentId: 'sa-1', taskId: 'delegate-1', tool: 'Read', category: 'read', inputSummary: 'src/task-store.js' }, { ...rep, actorId: 'sa-1', taskId: 'delegate-1' });
+ b.wait(11);
+ b.add('tool.call_completed', { callId: 'c-read', status: 'success', durationMs: 11, outputSummary: 'Read 34 lines' }, { ...rep, actorId: 'sa-1', taskId: 'delegate-1', parentEventId: read.eventId });
+ b.wait(4000);
+ b.add('task.completed', { taskId: 'delegate-1', summary: 'An in-memory task store.', artifactIds: [] }, { ...rep, taskId: 'delegate-1' });
+ b.add('tool.call_completed', { callId: 'c-agent', status: 'success', durationMs: 4799, outputSummary: 'Explore finished · 1 tool call · 20,520 tokens reported' }, { ...rep, actorId: 'main', taskId: 'turn-1', parentEventId: agentCall.eventId });
+ b.add(
+ 'agent.usage_reported',
+ { agentId: 'sa-1', model: 'claude-haiku-5-5', inputTokens: 2, outputTokens: 193, cacheReadTokens: 19620, cacheCreationTokens: 705, totalTokens: 20520, durationMs: 4795, toolUseCount: 1, callId: 'c-agent', source: 'claude-code.agent_result' },
+ { ...rep, actorId: 'sa-1' },
+ );
+ b.add('run.marker', { kind: 'compaction', label: 'Compaction started (manual)', agentId: 'main' }, { ...rep, actorId: 'main' });
+ // The fixed test run passes.
+ b.wait(1000);
+ const t2 = b.add('tool.call_started', { callId: 'c-test2', agentId: 'main', taskId: 'turn-1', tool: 'Bash', category: 'test', inputSummary: 'node --test' }, { ...rep, actorId: 'main', taskId: 'turn-1' });
+ b.wait(268);
+ b.add('tool.call_completed', { callId: 'c-test2', status: 'success', durationMs: 268, outputSummary: '12 tests: 12 passed, 0 failed', metrics: { tests: 12, passed: 12, failed: 0 } }, { ...rep, actorId: 'main', taskId: 'turn-1', parentEventId: t2.eventId });
+ // Stop: the unanswered permission is closed as denied (inferred).
+ b.wait(500);
+ b.add('tool.permission_resolved', { permissionId: 'perm-1', decision: 'denied', by: 'unknown' }, { provenance: 'inferred' });
+ b.add('tool.call_failed', { callId: 'c-perm', errorType: 'denied', message: 'No result was reported after the permission request; treated as denied (inferred)', durationMs: 7000 }, { provenance: 'inferred', actorId: 'main', taskId: 'turn-1', parentEventId: perm.eventId });
+ b.add('task.completed', { taskId: 'turn-1', summary: 'All 12 tests pass.', artifactIds: [] }, { ...rep, taskId: 'turn-1', visibility: 'sensitive' });
+ b.add('run.completed', { outcome: 'stopped', summary: 'Session ended (other)' }, rep);
+ return b.events;
+}
+
+/** A mutable fake wall clock. */
+export function fakeNow(startMs: number) {
+ let t = startMs;
+ return { now: () => t, set: (ms: number) => (t = ms), advance: (ms: number) => (t += ms) };
+}
+
+/** A store with the canonical fixture's run opened (world loaded, no events yet). */
+export function openFixture(opts: { now?: () => number } = {}): { store: StudioStore; events: RunEvent[] } {
+ const events = codingMissionFixture();
+ const store = createStudioStore(opts.now ? { now: opts.now } : {});
+ store.getState().openRun(events[0]!.runId);
+ store.getState().loadRun(detailFor(events));
+ return { store, events };
+}
+
+/** JSON round trip, like events arriving over HTTP. */
+export const wire = (v: T): unknown => JSON.parse(JSON.stringify(v)) as unknown;
diff --git a/apps/studio/src/state/timeline.test.ts b/apps/studio/src/state/timeline.test.ts
new file mode 100644
index 0000000..5d53508
--- /dev/null
+++ b/apps/studio/src/state/timeline.test.ts
@@ -0,0 +1,99 @@
+import { foldExecution } from '@runloop-world/core';
+import { CODING_MISSION, codingMissionFixture } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { buildTimelineRows, formatDuration, rowIndexBySeq, rowStatusAt, selectedRow } from './timeline';
+
+const events = codingMissionFixture();
+const t0 = Date.parse(events[0]!.timestamp);
+const timeOfSeq = (seq: number) => Date.parse(events[seq - 1]!.timestamp) - t0;
+const rows = buildTimelineRows(events, timeOfSeq);
+
+describe('timeline rows', () => {
+ it('merges each tool start and its completion into one row', () => {
+ const starts = events.filter((e) => e.type === 'tool.call_started');
+ const toolRows = rows.filter((r) => r.callId !== null);
+ expect(toolRows.map((r) => r.callId)).toEqual(starts.map((e) => (e.payload as { callId: string }).callId));
+ for (const r of toolRows) {
+ expect(r.endSeq).not.toBeNull();
+ expect(r.endSeq!).toBeGreaterThan(r.seq);
+ expect(r.durationMs).not.toBeNull();
+ }
+ // No separate rows for completions, nor for artifacts produced by a call.
+ expect(rows.some((r) => r.type === 'tool.call_completed' || r.type === 'artifact.created')).toBe(false);
+ });
+
+ it('shows the failing test run with the payload counts verbatim', () => {
+ const runs = rows.filter((r) => r.title.startsWith('run_command'));
+ expect(runs).toHaveLength(2);
+ const [failed, passed] = runs as [(typeof runs)[0], (typeof runs)[0]];
+ const done = events.find((e) => e.sequence === failed.endSeq)!;
+ expect(done.type).toBe('tool.call_completed');
+
+ expect(failed.status).toBe('error');
+ expect(failed.title).toBe(`run_command ${CODING_MISSION.testCommand}`);
+ expect(failed.detail).toBe('12 tests: 10 passed, 2 failed');
+ expect(failed.metrics).toEqual({ tests: 12, passed: 10, failed: 2 });
+ expect(failed.failingTests).toEqual([...CODING_MISSION.failingTests]);
+ expect(failed.durationMs).toBe(380);
+ expect(failed.artifactIds).toEqual(['art-2']);
+ expect(failed.actorId).toBe('tester');
+
+ expect(passed.status).toBe('ok');
+ expect(passed.metrics).toEqual({ tests: 12, passed: 12, failed: 0 });
+ expect(passed.seq).toBeGreaterThan(failed.seq);
+ });
+
+ it('shows a tool as running while the playhead is between start and end', () => {
+ const r = rows.find((x) => x.title.startsWith('run_command'))!;
+ expect(rowStatusAt(r, r.seq)).toBe('running');
+ expect(rowStatusAt(r, r.endSeq! - 1)).toBe('running');
+ expect(rowStatusAt(r, r.endSeq!)).toBe('error');
+ expect(rowStatusAt(r, r.seq - 1)).toBe('error'); // not started yet: final status (dimmed in the UI)
+ const info = rows.find((x) => x.type === 'task.created')!;
+ expect(rowStatusAt(info, 0)).toBe('info');
+ });
+
+ it('attributes task events to their owner and run events to the system', () => {
+ const blocked = rows.find((r) => r.type === 'task.blocked')!;
+ expect(blocked.actorId).toBe('tester');
+ expect(blocked.status).toBe('warn');
+ expect(rows[0]!.type).toBe('run.started');
+ expect(rows[0]!.actorId).toBe('system');
+ expect(rows.at(-1)!.type).toBe('run.completed');
+ expect(rows.at(-1)!.status).toBe('ok');
+ });
+
+ it('keeps rows in sequence order with times from the index', () => {
+ const seqs = rows.map((r) => r.seq);
+ expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
+ for (const r of rows) expect(r.tMs).toBe(timeOfSeq(r.seq));
+ });
+
+ it('formats durations', () => {
+ expect(formatDuration(380)).toBe('380 ms');
+ expect(formatDuration(1450)).toBe('1.4 s');
+ expect(formatDuration(125_000)).toBe('2 m 05 s');
+ });
+});
+
+describe('selected row', () => {
+ const exec = foldExecution(events);
+ const rows = buildTimelineRows(events, timeOfSeq, exec);
+ const bySeq = rowIndexBySeq(rows);
+
+ it('marks the event’s own row, or the agent’s latest row for an event that is no row', () => {
+ const failing = rows.findIndex((r) => r.status === 'error');
+ expect(selectedRow(rows, bySeq, rows, rows[failing]!.endSeq, 'tester')).toEqual({ index: failing, hiddenSeq: null });
+ const stateChange = events.find((e) => e.type === 'agent.state_changed' && !bySeq.has(e.sequence))!;
+ const agent = (stateChange.payload as { agentId: string }).agentId;
+ const { index } = selectedRow(rows, bySeq, rows, stateChange.sequence, agent);
+ expect(rows[index]!.actorId).toBe(agent);
+ expect(rows[index]!.seq).toBeLessThanOrEqual(stateChange.sequence);
+ });
+
+ it('marks nothing (and says so) when the filters hide the selected event’s row', () => {
+ const failing = rows.find((r) => r.status === 'error')!;
+ const visible = rows.filter((r) => r !== failing);
+ expect(selectedRow(rows, bySeq, visible, failing.endSeq, failing.actorId)).toEqual({ index: -1, hiddenSeq: failing.endSeq });
+ });
+});
diff --git a/apps/studio/src/state/timeline.ts b/apps/studio/src/state/timeline.ts
new file mode 100644
index 0000000..d6601af
--- /dev/null
+++ b/apps/studio/src/state/timeline.ts
@@ -0,0 +1,348 @@
+import { formatEvent, type ExecState } from '@runloop-world/core';
+import { isUnknownEvent, type AnyEvent, type TestMetrics } from '@runloop-world/events';
+import { describeEvent, failureText, MARKER_GLYPH } from './labels';
+import { categoryOfStart, eventProvenance } from './selection';
+
+export type RowStatus = 'running' | 'ok' | 'error' | 'warn' | 'info';
+
+/** One readable line of the Timeline. Text comes only from event payload fields. */
+export interface TimelineRow {
+ /** eventId of the row's first event. */
+ key: string;
+ type: string;
+ /** Sequence of the first event (a tool row starts at tool.call_started). */
+ seq: number;
+ /** Sequence of the event that finished the row (tool completion/failure), if any. */
+ endSeq: number | null;
+ /** Ms since run start of `seq`. */
+ tMs: number;
+ /** Agent id, or 'system'. */
+ actorId: string;
+ icon: string;
+ title: string;
+ detail: string | null;
+ /** Final status, once every merged event happened. */
+ status: RowStatus;
+ durationMs: number | null;
+ /** Test counts exactly as the payload reported them. */
+ metrics: TestMetrics | null;
+ failingTests: string[];
+ artifactIds: string[];
+ taskId: string | null;
+ callId: string | null;
+ /** Tool name for tool rows. */
+ tool: string | null;
+ /** Tool category, when the source reports one (traced runs). */
+ category: string | null;
+ /** Types of every event merged into the row (start, completion, artifacts). */
+ types: string[];
+ /** Sequences of every event merged into the row. */
+ seqs: number[];
+ /** Some merged event was reconstructed by Runloop rather than reported. */
+ inferred: boolean;
+ /** Sequence of the first merged event Runloop reconstructed (show the chip once the playhead reaches it). */
+ inferredSeq: number | null;
+ /**
+ * The subagent this row hands work to: an `Agent` call's subagent, or the
+ * owner of a delegated task (traced runs). Null otherwise.
+ */
+ delegateId: string | null;
+}
+
+const SEVERITY_TO_STATUS = { info: 'info', ok: 'ok', warn: 'warn', error: 'error' } as const;
+
+const ICONS: Record = {
+ 'run.started': '▶',
+ 'run.paused': '⏸',
+ 'run.resumed': '⏵',
+ 'run.completed': '■',
+ 'agent.registered': '+',
+ 'agent.state_changed': '◐',
+ 'agent.message_sent': '✉',
+ 'task.created': '◇',
+ 'task.assigned': '→',
+ 'task.started': '◆',
+ 'task.blocked': '⏳',
+ 'task.completed': '✓',
+ 'task.failed': '✗',
+ 'tool.call_started': '⚙',
+ 'tool.permission_requested': '🔒',
+ 'tool.permission_resolved': '🔑',
+ 'artifact.created': '📄',
+ 'agent.usage_reported': '¤',
+};
+
+/** Task kinds whose created / assigned / started events read best as one row (traced runs). */
+const MERGED_TASK_KINDS = new Set(['turn', 'delegated']);
+
+/** Agent state changes worth a Timeline row (the rest is in Logs). */
+const NOTABLE_AGENT_STATES = new Set(['failed', 'blocked']);
+
+/**
+ * Turn the ordered event log into Timeline rows. A tool call's start and its
+ * completion (or failure) become one row carrying the duration and status;
+ * artifacts produced by a call attach to that row. In traced runs a turn's or
+ * delegation's created / assigned / started events also share one row, and an
+ * `Agent` call row links to the subagent it started. Rows are independent of
+ * the playhead: use {@link rowStatusAt} / `row.seq > playheadSeq` when rendering.
+ * `exec` (the latest state) supplies agent and task names for the wording.
+ */
+export function buildTimelineRows(events: readonly AnyEvent[], timeOfSeq: (seq: number) => number, exec?: ExecState): TimelineRow[] {
+ const rows: TimelineRow[] = [];
+ const byCall = new Map();
+ const taskOwner = new Map();
+ /** tool.call_started eventId → callId (delegations point at the call that started them). */
+ const callOfStart = new Map();
+ /** Delegated taskId → the row of the call that delegated it. */
+ const callRowOfTask = new Map();
+ /** taskId → its merged task row (turns and delegations). */
+ const taskRows = new Map();
+ /** permissionId → the agent that asked. */
+ const permAgent = new Map();
+
+ const base = (e: AnyEvent): TimelineRow => ({
+ key: e.eventId,
+ type: e.type,
+ seq: e.sequence,
+ endSeq: null,
+ tMs: timeOfSeq(e.sequence),
+ actorId: e.actorId ?? (e.taskId ? taskOwner.get(e.taskId) : undefined) ?? 'system',
+ icon: ICONS[e.type] ?? '•',
+ title: e.type,
+ detail: null,
+ status: 'info',
+ durationMs: null,
+ metrics: null,
+ failingTests: [],
+ artifactIds: [],
+ taskId: e.taskId ?? null,
+ callId: null,
+ tool: null,
+ category: null,
+ types: [e.type],
+ seqs: [e.sequence],
+ inferred: eventProvenance(e) === 'inferred',
+ inferredSeq: eventProvenance(e) === 'inferred' ? e.sequence : null,
+ delegateId: null,
+ });
+
+ /** Fold a later event (completion, artifact) into an existing row. */
+ const merge = (row: TimelineRow, e: AnyEvent) => {
+ if (!row.types.includes(e.type)) row.types.push(e.type);
+ row.seqs.push(e.sequence);
+ if (eventProvenance(e) === 'inferred') {
+ row.inferred = true;
+ row.inferredSeq ??= e.sequence;
+ }
+ };
+
+ for (const e of events) {
+ if (isUnknownEvent(e)) {
+ const f = formatEvent(e);
+ rows.push({ ...base(e), icon: '⚠', title: f.label, detail: f.detail ?? e.type, status: 'warn' });
+ continue;
+ }
+ switch (e.type) {
+ case 'tool.call_started': {
+ const p = e.payload;
+ const row: TimelineRow = {
+ ...base(e),
+ actorId: p.agentId,
+ title: `${p.tool} ${p.inputSummary}`.trim(),
+ status: 'running',
+ taskId: p.taskId ?? null,
+ callId: p.callId,
+ tool: p.tool,
+ category: categoryOfStart(e),
+ };
+ byCall.set(p.callId, row);
+ callOfStart.set(e.eventId, p.callId);
+ rows.push(row);
+ break;
+ }
+ case 'tool.call_completed': {
+ const p = e.payload;
+ const row = byCall.get(p.callId);
+ if (!row) {
+ rows.push(orphan(base(e), e));
+ break;
+ }
+ merge(row, e);
+ row.endSeq = e.sequence;
+ row.status = p.status === 'success' ? 'ok' : 'error';
+ row.icon = p.status === 'success' ? '✓' : '✗';
+ row.detail = p.outputSummary;
+ row.durationMs = p.durationMs;
+ row.metrics = p.metrics ?? null;
+ row.failingTests = p.failingTests ?? [];
+ for (const id of p.outputArtifactIds ?? []) if (!row.artifactIds.includes(id)) row.artifactIds.push(id);
+ break;
+ }
+ case 'tool.call_failed': {
+ const p = e.payload;
+ const row = byCall.get(p.callId);
+ if (!row) {
+ rows.push(orphan(base(e), e));
+ break;
+ }
+ merge(row, e);
+ row.endSeq = e.sequence;
+ row.status = 'error';
+ row.icon = '✗';
+ const what = failureText(p.errorType);
+ row.detail = `${what.charAt(0).toUpperCase()}${what.slice(1)}: ${p.message}`;
+ row.durationMs = p.durationMs;
+ break;
+ }
+ case 'artifact.created': {
+ const p = e.payload;
+ const row = p.producedByCallId ? byCall.get(p.producedByCallId) : undefined;
+ if (row) {
+ merge(row, e);
+ if (!row.artifactIds.includes(p.artifactId)) row.artifactIds.push(p.artifactId);
+ } else {
+ rows.push({ ...plain(base(e), e, exec), artifactIds: [p.artifactId] });
+ }
+ break;
+ }
+ case 'agent.state_changed':
+ if (NOTABLE_AGENT_STATES.has(e.payload.state)) rows.push(plain(base(e), e, exec));
+ break;
+ case 'task.created': {
+ const p = e.payload;
+ const row = plain(base(e), e, exec);
+ if (MERGED_TASK_KINDS.has(p.kind)) {
+ taskRows.set(p.taskId, row);
+ // A turn is about the agent that works it, not the user who opened it.
+ if (p.kind === 'turn') row.actorId = exec?.tasks[p.taskId]?.ownerId ?? row.actorId;
+ }
+ const viaCall = e.parentEventId ? callOfStart.get(e.parentEventId) : undefined;
+ if (p.kind === 'delegated' && viaCall) callRowOfTask.set(p.taskId, byCall.get(viaCall)!);
+ rows.push(row);
+ break;
+ }
+ case 'task.assigned':
+ case 'task.started': {
+ const p = e.payload;
+ taskOwner.set(p.taskId, p.agentId);
+ const callRow = callRowOfTask.get(p.taskId);
+ if (callRow) callRow.delegateId = p.agentId;
+ const taskRow = taskRows.get(p.taskId);
+ // Merge only while nothing else happened in between (the row still reads as one moment).
+ if (taskRow && rows.at(-1) === taskRow) {
+ merge(taskRow, e);
+ if (exec?.tasks[p.taskId]?.kind === 'delegated') taskRow.delegateId = p.agentId;
+ break;
+ }
+ rows.push(plain(base(e), e, exec));
+ break;
+ }
+ case 'tool.permission_requested':
+ permAgent.set(e.payload.permissionId, e.payload.agentId);
+ rows.push(plain(base(e), e, exec));
+ break;
+ case 'tool.permission_resolved': {
+ const row = plain(base(e), e, exec);
+ if (!e.actorId) row.actorId = permAgent.get(e.payload.permissionId) ?? row.actorId;
+ rows.push(row);
+ break;
+ }
+ case 'run.marker':
+ rows.push({ ...plain(base(e), e, exec), icon: MARKER_GLYPH[e.payload.kind] ?? '⚑' });
+ break;
+ default:
+ rows.push(plain(base(e), e, exec));
+ }
+ }
+ return rows;
+}
+
+/**
+ * Status of a row as seen from the playhead: a tool call that started but has
+ * not finished at the playhead shows as running. Rows after the playhead keep
+ * their final status (the UI dims them).
+ */
+export function rowStatusAt(row: TimelineRow, playheadSeq: number): RowStatus {
+ const started = row.seq <= playheadSeq;
+ if (row.callId !== null && started && (row.endSeq === null || row.endSeq > playheadSeq)) return 'running';
+ return row.status;
+}
+
+/** Index of the row that holds event `seq` (a tool row holds its start, result and artifacts). */
+export function rowIndexOfSeq(rows: readonly TimelineRow[], seq: number | null): number {
+ if (seq === null) return -1;
+ return rows.findIndex((r) => r.seqs.includes(seq));
+}
+
+/** A row worded from payload fields only (studio labels when `exec` is given, else the core formatter). */
+function plain(row: TimelineRow, e: AnyEvent, exec?: ExecState): TimelineRow {
+ const f = exec ? describeEvent(e, exec) : formatEvent(e);
+ return { ...row, title: f.label, detail: f.detail, status: SEVERITY_TO_STATUS[f.severity] };
+}
+
+/** A tool result whose start was never seen (shown on its own rather than dropped). */
+function orphan(row: TimelineRow, e: AnyEvent): TimelineRow {
+ const r = plain(row, e);
+ return { ...r, icon: r.status === 'ok' ? '✓' : '✗' };
+}
+
+/** "380 ms", "1.4 s", "2 m 05 s". */
+export function formatDuration(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)} ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
+ const s = Math.round(ms / 1000);
+ return `${Math.floor(s / 60)} m ${String(s % 60).padStart(2, '0')} s`;
+}
+
+export const STATUS_LABEL: Record = {
+ running: 'running',
+ ok: 'success',
+ error: 'error',
+ warn: 'warning',
+ info: 'info',
+};
+
+/** Index of the last row whose first event is at or before `seq` (rows are in sequence order), or -1. */
+export function lastRowAtOrBefore(rows: readonly TimelineRow[], seq: number): number {
+ let lo = 0;
+ let hi = rows.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (rows[mid]!.seq <= seq) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo - 1;
+}
+
+/** The row that holds each event sequence. */
+export function rowIndexBySeq(rows: readonly TimelineRow[]): Map {
+ const m = new Map();
+ for (const r of rows) for (const seq of r.seqs) m.set(seq, r);
+ return m;
+}
+
+/**
+ * Which visible row to mark as selected. The selected event's own row; for an
+ * event that is no row (a state change), the agent's latest visible row
+ * before it. When the filters hide the event's row, no other row is marked:
+ * `hiddenSeq` says so (the timeline must not contradict the inspector).
+ */
+export function selectedRow(
+ rows: readonly TimelineRow[],
+ rowOfSeq: ReadonlyMap,
+ visibleRows: readonly TimelineRow[],
+ selectedSeq: number | null,
+ selectedAgentId: string | null,
+): { index: number; hiddenSeq: number | null } {
+ if (selectedSeq === null) return { index: -1, hiddenSeq: null };
+ const own = rowOfSeq.get(selectedSeq);
+ if (own) {
+ const index = visibleRows === rows ? rows.indexOf(own) : visibleRows.indexOf(own);
+ return index >= 0 ? { index, hiddenSeq: null } : { index: -1, hiddenSeq: selectedSeq };
+ }
+ if (selectedAgentId === null) return { index: -1, hiddenSeq: null };
+ for (let i = lastRowAtOrBefore(visibleRows, selectedSeq); i >= 0; i--) {
+ if (visibleRows[i]!.actorId === selectedAgentId) return { index: i, hiddenSeq: null };
+ }
+ return { index: -1, hiddenSeq: null };
+}
diff --git a/apps/studio/src/state/view.test.ts b/apps/studio/src/state/view.test.ts
new file mode 100644
index 0000000..d7767ff
--- /dev/null
+++ b/apps/studio/src/state/view.test.ts
@@ -0,0 +1,70 @@
+import { foldExecution } from '@runloop-world/core';
+import { codingMissionFixture, runBuilder } from '@runloop-world/testkit';
+import { describe, expect, it } from 'vitest';
+import { flowChips, formatClockPair } from './view';
+
+const events = codingMissionFixture();
+const at = (pred: (e: (typeof events)[number]) => boolean) => foldExecution(events.slice(0, events.findIndex(pred) + 1));
+const statuses = (exec: ReturnType) => Object.fromEntries(flowChips(exec).map((c) => [c.kind, c.view.text]));
+
+describe('agent flow chips', () => {
+ it('follow the mission: current, then done', () => {
+ expect(statuses(at((e) => e.type === 'task.started'))).toEqual({
+ research: 'in progress',
+ code: 'pending',
+ test: 'pending',
+ });
+ expect(statuses(at((e) => e.type === 'task.blocked'))).toEqual({
+ research: 'done',
+ code: 'done',
+ test: 'blocked',
+ fix: 'pending',
+ });
+ const final = flowChips(foldExecution(events));
+ expect(final.map((c) => c.label)).toEqual(['Research', 'Code', 'Test', 'Fix']);
+ expect(final.every((c) => c.status === 'done' && c.view.icon === '✓')).toBe(true);
+ });
+
+ it('shows open work of an ended run as unfinished, not in progress', () => {
+ const toolStart = events.findIndex((e) => e.type === 'tool.call_started' && e.payload.tool === 'write_file');
+ const b = runBuilder({ runId: events[0]!.runId });
+ for (const e of events.slice(0, toolStart + 1)) b.add(e.type, e.payload as never);
+ expect(statuses(foldExecution(b.events)).code).toBe('in progress');
+ b.add('run.completed', { outcome: 'stopped', summary: 'Stopped by user' });
+ const chips = flowChips(foldExecution(b.events));
+ expect(Object.fromEntries(chips.map((c) => [c.kind, c.view.text]))).toEqual({
+ research: 'done',
+ code: 'unfinished',
+ test: 'pending',
+ });
+ expect(chips[1]!.view.icon).toBe('■');
+ });
+
+ it('marks a failed task kind as failed with an icon and text', () => {
+ const b = runBuilder();
+ b.add('run.started', {
+ title: 't',
+ missionId: 'm',
+ worldId: 'research-campus',
+ seed: 1,
+ adapter: { id: 'a', kind: 'scripted', label: 'A' },
+ workspace: 'w',
+ });
+ b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'test', model: null, tools: [], embodied: true });
+ b.add('task.created', { taskId: 'T1', title: 'Deploy', kind: 'deploy', dependsOn: [] });
+ b.add('task.assigned', { taskId: 'T1', agentId: 'a1' });
+ b.add('task.started', { taskId: 'T1', agentId: 'a1' });
+ b.add('task.failed', { taskId: 'T1', reason: 'boom' });
+ const [chip] = flowChips(foldExecution(b.events));
+ expect(chip).toMatchObject({ kind: 'deploy', status: 'failed', label: 'Deploy' });
+ expect(chip!.view).toEqual({ icon: '✗', text: 'failed', tone: 'err' });
+ });
+});
+
+describe('formatClockPair', () => {
+ it('formats "mm:ss / mm:ss"', () => {
+ expect(formatClockPair(92_000, 195_000)).toBe('01:32 / 03:15');
+ expect(formatClockPair(0, 0)).toBe('00:00 / 00:00');
+ expect(formatClockPair(-5, 999)).toBe('00:00 / 00:00');
+ });
+});
diff --git a/apps/studio/src/state/view.ts b/apps/studio/src/state/view.ts
new file mode 100644
index 0000000..9675159
--- /dev/null
+++ b/apps/studio/src/state/view.ts
@@ -0,0 +1,141 @@
+import {
+ formatClock,
+ selectAgentFlow,
+ type ExecState,
+ type FlowChip,
+ type RunExecStatus,
+ type TaskStatus,
+ type ToolCallStatus,
+} from '@runloop-world/core';
+import type { RunStatus, TaskKind } from '@runloop-world/events';
+import type { Activity, CompiledWorld, PresentationState } from '@runloop-world/world-engine';
+import { identityColor } from './identity';
+
+/** Every status is shown as icon + text (never colour alone); `tone` picks the colour. */
+export interface StatusView {
+ icon: string;
+ text: string;
+ tone: 'ok' | 'err' | 'warn' | 'info' | 'live' | 'muted';
+}
+
+export const KIND_LABEL: Record = {
+ research: 'Research',
+ code: 'Code',
+ test: 'Test',
+ fix: 'Fix',
+ approval: 'Approval',
+ deploy: 'Deploy',
+ turn: 'Turn',
+ delegated: 'Delegated',
+};
+
+const FLOW_STATUS: Record = {
+ pending: { icon: '○', text: 'pending', tone: 'muted' },
+ current: { icon: '◉', text: 'in progress', tone: 'live' },
+ blocked: { icon: '⏳', text: 'blocked', tone: 'warn' },
+ done: { icon: '✓', text: 'done', tone: 'ok' },
+ failed: { icon: '✗', text: 'failed', tone: 'err' },
+};
+
+/** Work left open when the run ended (stopped, interrupted, failed): nothing is in progress any more. */
+const UNFINISHED: StatusView = { icon: '■', text: 'unfinished', tone: 'muted' };
+
+const RUN_ENDED: ReadonlySet = new Set(['succeeded', 'failed', 'stopped', 'interrupted']);
+
+/** The run has a terminal status: nothing executes any more. */
+export function runHasEnded(exec: ExecState): boolean {
+ return RUN_ENDED.has(exec.run.status);
+}
+
+export interface FlowChipView extends FlowChip {
+ label: string;
+ view: StatusView;
+}
+
+/** Agent Flow chips (core selectAgentFlow) with labels and status icons. */
+export function flowChips(exec: ExecState): FlowChipView[] {
+ const ended = runHasEnded(exec);
+ return selectAgentFlow(exec).map((c) => ({
+ ...c,
+ label: KIND_LABEL[c.kind],
+ view: ended && (c.status === 'current' || c.status === 'blocked') ? UNFINISHED : FLOW_STATUS[c.status],
+ }));
+}
+
+const RUN_STATUS: Record = {
+ pending: { icon: '○', text: 'pending', tone: 'muted' },
+ running: { icon: '●', text: 'running', tone: 'live' },
+ paused: { icon: '⏸', text: 'paused', tone: 'warn' },
+ succeeded: { icon: '✓', text: 'succeeded', tone: 'ok' },
+ failed: { icon: '✗', text: 'failed', tone: 'err' },
+ stopped: { icon: '■', text: 'stopped', tone: 'warn' },
+ interrupted: { icon: '⚠', text: 'interrupted', tone: 'warn' },
+};
+
+/** A traced session that ended normally is stored as `stopped`; it reads as "ended" (plan §6.6). */
+const TRACED_ENDED: StatusView = { icon: '■', text: 'ended', tone: 'muted' };
+
+export function runStatusView(status: RunStatus | RunExecStatus, paused = false, traced = false): StatusView {
+ if (traced && status === 'stopped') return TRACED_ENDED;
+ return paused && status === 'running' ? RUN_STATUS.paused : RUN_STATUS[status];
+}
+
+const CHARACTER_STATUS: Record = {
+ idle: { icon: '◌', text: 'idle', tone: 'muted' },
+ moving: { icon: '➜', text: 'moving', tone: 'info' },
+ working: { icon: '⚙', text: 'working', tone: 'live' },
+ waiting: { icon: '…', text: 'waiting', tone: 'warn' },
+ awaiting_approval: { icon: '🔒', text: 'awaiting approval', tone: 'warn' },
+ succeeded: { icon: '✓', text: 'done', tone: 'ok' },
+ failed: { icon: '✗', text: 'failed', tone: 'err' },
+ paused: { icon: '⏸', text: 'paused', tone: 'warn' },
+};
+
+export function characterStatusView(state: PresentationState | Activity): StatusView {
+ return CHARACTER_STATUS[state];
+}
+
+/** Scrubber clock: "mm:ss / mm:ss". */
+export function formatClockPair(tMs: number, endMs: number): string {
+ return `${formatClock(tMs)} / ${formatClock(Math.max(tMs, endMs))}`;
+}
+
+export interface AgentLook {
+ name: string;
+ /** CSS colour: the agent's identity hue (tokens.css --rl-id-N), neutral for non-embodied agents and 'system'. */
+ color: string;
+ embodied: boolean;
+}
+
+/** Display name and identity colour for an agent id ('system' and unknown ids included). */
+export function agentLook(exec: ExecState, _world: CompiledWorld | null, agentId: string): AgentLook {
+ const a = exec.agents[agentId];
+ if (!a) return { name: agentId === 'system' ? 'System' : agentId, color: 'var(--rl-id-neutral)', embodied: false };
+ return { name: a.name, color: identityColor(exec, agentId), embodied: a.embodied };
+}
+
+const TASK_STATUS: Record = {
+ pending: { icon: '○', text: 'pending', tone: 'muted' },
+ assigned: { icon: '→', text: 'assigned', tone: 'info' },
+ in_progress: { icon: '◉', text: 'in progress', tone: 'live' },
+ blocked: { icon: '⏳', text: 'blocked', tone: 'warn' },
+ completed: { icon: '✓', text: 'completed', tone: 'ok' },
+ failed: { icon: '✗', text: 'failed', tone: 'err' },
+};
+
+/** A task's status; open tasks of a run that has ended show as unfinished. */
+export function taskStatusView(status: TaskStatus, runEnded = false): StatusView {
+ if (runEnded && (status === 'assigned' || status === 'in_progress' || status === 'blocked')) return UNFINISHED;
+ return TASK_STATUS[status];
+}
+
+const CALL_STATUS: Record = {
+ running: { icon: '⚙', text: 'running', tone: 'live' },
+ success: { icon: '✓', text: 'success', tone: 'ok' },
+ error: { icon: '✗', text: 'error', tone: 'err' },
+ failed: { icon: '⊘', text: 'could not run', tone: 'err' },
+};
+
+export function callStatusView(status: ToolCallStatus): StatusView {
+ return CALL_STATUS[status];
+}
diff --git a/apps/studio/src/ui/Breadcrumbs.module.css b/apps/studio/src/ui/Breadcrumbs.module.css
new file mode 100644
index 0000000..900d7f3
--- /dev/null
+++ b/apps/studio/src/ui/Breadcrumbs.module.css
@@ -0,0 +1,34 @@
+.nav {
+ min-width: 0;
+}
+.list {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 6px;
+}
+.item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.sep {
+ flex: none;
+ color: var(--rl-text-faint);
+}
+.link {
+ color: var(--rl-text-muted);
+ white-space: nowrap;
+ border-radius: 4px;
+}
+.link:hover {
+ color: var(--rl-text);
+}
+.current {
+ color: var(--rl-text);
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/apps/studio/src/ui/Breadcrumbs.tsx b/apps/studio/src/ui/Breadcrumbs.tsx
new file mode 100644
index 0000000..51d52af
--- /dev/null
+++ b/apps/studio/src/ui/Breadcrumbs.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import styles from './Breadcrumbs.module.css';
+import { Icon } from './Icon';
+
+export interface Crumb {
+ label: ReactNode;
+ href?: string;
+}
+
+/** Where you are: Area › Run. The last crumb is the current page. */
+export function Breadcrumbs({ items }: { items: readonly Crumb[] }) {
+ return (
+
+
+ {items.map((c, i) => {
+ const last = i === items.length - 1;
+ return (
+
+ {i > 0 && }
+ {c.href && !last ? (
+
+ {c.label}
+
+ ) : (
+
+ {c.label}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/studio/src/ui/Button.module.css b/apps/studio/src/ui/Button.module.css
new file mode 100644
index 0000000..803a5ac
--- /dev/null
+++ b/apps/studio/src/ui/Button.module.css
@@ -0,0 +1,88 @@
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--rl-space-2);
+ min-height: var(--rl-control-h);
+ padding: 0 var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ font-size: var(--rl-text-sm);
+ font-weight: 500;
+ line-height: 1;
+ white-space: nowrap;
+ user-select: none;
+ transition:
+ background-color var(--rl-dur-instant) var(--rl-ease-out),
+ color var(--rl-dur-instant) var(--rl-ease-out),
+ box-shadow var(--rl-dur-instant) var(--rl-ease-out);
+}
+.button:disabled {
+ opacity: 0.45;
+}
+
+.sm {
+ min-height: var(--rl-control-h-sm);
+ padding: 0 10px;
+ font-size: var(--rl-text-xs);
+ gap: 6px;
+}
+
+.iconOnly {
+ width: var(--rl-control-h);
+ padding: 0;
+}
+.iconOnly.sm {
+ width: var(--rl-control-h-sm);
+}
+
+/* Lime primary: the one main action per screen. */
+.primary {
+ background: var(--rl-accent-fill);
+ color: var(--rl-on-accent);
+ font-weight: 600;
+}
+.primary:hover:not(:disabled) {
+ background: var(--rl-accent-fill-hover);
+}
+
+.secondary {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+.secondary:hover:not(:disabled) {
+ background: var(--rl-surface-3);
+}
+
+.ghost {
+ background: transparent;
+ color: var(--rl-text-muted);
+}
+.ghost:hover:not(:disabled) {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+
+.danger {
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+ color: var(--rl-failure);
+ font-weight: 600;
+}
+.danger:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--rl-failure) 22%, transparent);
+}
+
+/* Toggle buttons show their pressed state with the selection accent (plus aria-pressed for AT). */
+.button[aria-pressed='true'] {
+ background: var(--rl-accent-soft);
+ color: var(--rl-accent-text);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--rl-accent-text) 40%, transparent);
+}
+
+.spin {
+ animation: spin 900ms linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/apps/studio/src/ui/Button.tsx b/apps/studio/src/ui/Button.tsx
new file mode 100644
index 0000000..e3b62fb
--- /dev/null
+++ b/apps/studio/src/ui/Button.tsx
@@ -0,0 +1,57 @@
+import Link from 'next/link';
+import { forwardRef, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode } from 'react';
+import styles from './Button.module.css';
+import { Icon, type IconName } from './Icon';
+
+export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
+export type ButtonSize = 'sm' | 'md';
+
+/** Class names for a button look (also used by links styled as buttons). */
+export function buttonClass(variant: ButtonVariant = 'secondary', size: ButtonSize = 'md', iconOnly = false, extra?: string): string {
+ return [styles.button, styles[variant], styles[size], iconOnly ? styles.iconOnly : '', extra ?? ''].filter(Boolean).join(' ');
+}
+
+interface CommonProps {
+ /** Lime primary is for the one main action on a screen. */
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ icon?: IconName;
+ /** Icon-only buttons must carry an aria-label. */
+ iconOnly?: boolean;
+ trailing?: ReactNode;
+}
+
+export type ButtonProps = ButtonHTMLAttributes & CommonProps & { busy?: boolean };
+
+export const Button = forwardRef(function Button(
+ { variant, size, icon, iconOnly, trailing, busy, className, children, type, disabled, ...rest },
+ ref,
+) {
+ return (
+
+ {icon && }
+ {!iconOnly && children}
+ {trailing}
+
+ );
+});
+
+export type ButtonLinkProps = AnchorHTMLAttributes & CommonProps & { href: string };
+
+/** A navigation link that looks like a button. */
+export function ButtonLink({ variant, size, icon, iconOnly, trailing, className, children, href, ...rest }: ButtonLinkProps) {
+ return (
+
+ {icon && }
+ {!iconOnly && children}
+ {trailing}
+
+ );
+}
diff --git a/apps/studio/src/ui/Card.module.css b/apps/studio/src/ui/Card.module.css
new file mode 100644
index 0000000..dfa902b
--- /dev/null
+++ b/apps/studio/src/ui/Card.module.css
@@ -0,0 +1,34 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+ box-shadow: var(--rl-shadow-card);
+}
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--rl-space-3);
+ padding: var(--rl-space-4) var(--rl-space-5) var(--rl-space-3);
+}
+.heading {
+ min-width: 0;
+}
+.title {
+ font-size: var(--rl-text-md);
+ font-weight: 600;
+ letter-spacing: -0.005em;
+}
+.subtitle {
+ margin-top: 2px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ flex: none;
+}
diff --git a/apps/studio/src/ui/Card.tsx b/apps/studio/src/ui/Card.tsx
new file mode 100644
index 0000000..494ffec
--- /dev/null
+++ b/apps/studio/src/ui/Card.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react';
+import styles from './Card.module.css';
+
+/** A tonal surface with an optional header (title, subtitle, actions). */
+export function Card({
+ title,
+ subtitle,
+ actions,
+ children,
+ className,
+ as: As = 'section',
+ labelledBy,
+}: {
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ actions?: ReactNode;
+ children?: ReactNode;
+ className?: string;
+ as?: 'section' | 'div' | 'article';
+ labelledBy?: string;
+}) {
+ return (
+
+ {(title || actions) && (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle &&
{subtitle}
}
+
+ {actions && {actions}
}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/apps/studio/src/ui/Chip.module.css b/apps/studio/src/ui/Chip.module.css
new file mode 100644
index 0000000..8b4be17
--- /dev/null
+++ b/apps/studio/src/ui/Chip.module.css
@@ -0,0 +1,66 @@
+.chip,
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: var(--rl-radius-sm);
+ font-size: var(--rl-text-2xs);
+ font-weight: 500;
+ line-height: 1;
+ white-space: nowrap;
+ color: var(--rl-text-muted);
+ background: var(--rl-surface-2);
+}
+.mono {
+ font-family: var(--rl-font-mono);
+ font-size: var(--rl-text-2xs);
+}
+
+.badge {
+ font-weight: 600;
+ border-radius: var(--rl-radius-pill);
+ padding: 0 8px 0 6px;
+ background: color-mix(in srgb, currentColor 12%, var(--rl-surface));
+}
+.badgeIcon {
+ flex: none;
+}
+
+.chip[data-tone='ok'],
+.badge[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.chip[data-tone='err'],
+.badge[data-tone='err'] {
+ color: var(--rl-failure);
+}
+.chip[data-tone='warn'],
+.badge[data-tone='warn'] {
+ color: var(--rl-warning);
+}
+.chip[data-tone='live'],
+.badge[data-tone='live'],
+.chip[data-tone='accent'],
+.badge[data-tone='accent'] {
+ color: var(--rl-accent-text);
+}
+.chip[data-tone='info'],
+.badge[data-tone='info'] {
+ color: var(--rl-info);
+}
+.badge[data-tone='muted'] {
+ color: var(--rl-text-muted);
+}
+/* Opaque base under the tint: a chip never stacks its tint on a tinted row (AA on any background). */
+.chip[data-tone] {
+ background: color-mix(in srgb, currentColor 12%, var(--rl-surface));
+}
+
+.dot {
+ display: inline-block;
+ flex: none;
+ border-radius: 50%;
+ background: var(--dot);
+}
diff --git a/apps/studio/src/ui/Chip.tsx b/apps/studio/src/ui/Chip.tsx
new file mode 100644
index 0000000..9782d1a
--- /dev/null
+++ b/apps/studio/src/ui/Chip.tsx
@@ -0,0 +1,45 @@
+import type { CSSProperties, ReactNode } from 'react';
+import type { StatusView } from '../state/view';
+import styles from './Chip.module.css';
+import { GlyphIcon, Icon, type IconName } from './Icon';
+
+export type Tone = StatusView['tone'] | 'accent';
+
+/** A small neutral fact: a count, a duration, a tag. Tone adds colour; the text always carries the meaning. */
+export function Chip({
+ children,
+ tone,
+ icon,
+ title,
+ mono,
+ className,
+}: {
+ children: ReactNode;
+ tone?: Tone;
+ icon?: IconName;
+ title?: string;
+ mono?: boolean;
+ className?: string;
+}) {
+ return (
+
+ {icon && }
+ {children}
+
+ );
+}
+
+/** Icon + text status (never colour alone). */
+export function StatusBadge({ view, className, title }: { view: StatusView; className?: string; title?: string }) {
+ return (
+
+
+ {view.text}
+
+ );
+}
+
+/** An agent's identity colour as a dot (identity never signals state). */
+export function AgentDot({ color, size = 8 }: { color: string; size?: number }) {
+ return ;
+}
diff --git a/apps/studio/src/ui/CommandPalette.module.css b/apps/studio/src/ui/CommandPalette.module.css
new file mode 100644
index 0000000..8816dff
--- /dev/null
+++ b/apps/studio/src/ui/CommandPalette.module.css
@@ -0,0 +1,89 @@
+.panel {
+ width: min(620px, 100%);
+}
+.search {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ padding: 0 var(--rl-space-4);
+ height: 56px;
+ color: var(--rl-text-muted);
+ box-shadow: inset 0 -1px 0 var(--rl-line);
+}
+.input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ border: 0;
+ background: transparent;
+ color: var(--rl-text);
+ font-size: var(--rl-text-lg);
+}
+.input:focus,
+.input:focus-visible {
+ outline: none;
+ box-shadow: none;
+}
+.input::placeholder {
+ color: var(--rl-text-faint);
+}
+.list {
+ max-height: min(420px, 60vh);
+ overflow-y: auto;
+ padding: var(--rl-space-2);
+}
+.group {
+ padding: var(--rl-space-3) var(--rl-space-3) var(--rl-space-1);
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.option {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ min-height: 38px;
+ padding: 0 var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ cursor: pointer;
+}
+.option[data-active='true'] {
+ background: var(--rl-surface-2);
+ box-shadow: inset 2px 0 0 var(--rl-accent-text);
+}
+.option[aria-disabled='true'] {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+.label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.hint {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ white-space: nowrap;
+}
+.empty {
+ padding: var(--rl-space-6) var(--rl-space-4);
+ color: var(--rl-text-muted);
+ text-align: center;
+}
+.footer {
+ display: flex;
+ gap: var(--rl-space-4);
+ padding: var(--rl-space-2) var(--rl-space-4);
+ color: var(--rl-text-faint);
+ font-size: var(--rl-text-xs);
+ box-shadow: inset 0 1px 0 var(--rl-line);
+}
+.footer span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
diff --git a/apps/studio/src/ui/CommandPalette.tsx b/apps/studio/src/ui/CommandPalette.tsx
new file mode 100644
index 0000000..244036e
--- /dev/null
+++ b/apps/studio/src/ui/CommandPalette.tsx
@@ -0,0 +1,127 @@
+'use client';
+import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
+import { rankCommands, type Command } from '../state/commands';
+import styles from './CommandPalette.module.css';
+import { Dialog } from './Dialog';
+import { Icon } from './Icon';
+import { Kbd } from './Kbd';
+import { scrollWithin } from './scroll';
+
+/**
+ * ⌘K / Ctrl-K palette: a combobox over a grouped listbox. ↑/↓ move, Enter
+ * runs, Escape closes. Disabled commands stay visible with their reason.
+ */
+export function CommandPalette({ open, onClose, commands }: { open: boolean; onClose: () => void; commands: readonly Command[] }) {
+ const id = useId();
+ const input = useRef(null);
+ const list = useRef(null);
+ const [query, setQuery] = useState('');
+ const [active, setActive] = useState(0);
+ const results = useMemo(() => rankCommands(commands, query), [commands, query]);
+
+ useEffect(() => {
+ if (open) {
+ setQuery('');
+ setActive(0);
+ }
+ }, [open]);
+ useEffect(() => setActive(0), [query]);
+ useEffect(() => {
+ const el = list.current?.querySelector('[data-active="true"]');
+ if (list.current && el) scrollWithin(list.current, el);
+ }, [active]);
+
+ const run = (c: Command | undefined) => {
+ if (!c || c.disabled) return;
+ onClose();
+ c.run();
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActive((i) => Math.min(results.length - 1, i + 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActive((i) => Math.max(0, i - 1));
+ } else if (e.key === 'Home') {
+ setActive(0);
+ } else if (e.key === 'End') {
+ setActive(results.length - 1);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ run(results[active]);
+ }
+ };
+
+ // Group headings in result order (ranking may interleave groups when searching).
+ let lastGroup = '';
+ return (
+
+
+
+
+ Search commands, runs and pages
+
+ setQuery(e.target.value)}
+ onKeyDown={onKey}
+ />
+
+
+
+ {results.length === 0 &&
No command matches “{query}”.
}
+ {results.map((c, i) => {
+ const heading = c.group !== lastGroup ? c.group : null;
+ lastGroup = c.group;
+ return (
+
+ {heading && (
+
+ {heading}
+
+ )}
+
setActive(i)}
+ onClick={() => run(c)}
+ >
+ {c.label}
+ {c.hint && {c.hint} }
+ {c.shortcut && }
+
+
+ );
+ })}
+
+
+
+ move
+
+
+ run
+
+
+ close
+
+
+
+ );
+}
diff --git a/apps/studio/src/ui/CommandSnippet.module.css b/apps/studio/src/ui/CommandSnippet.module.css
new file mode 100644
index 0000000..4d453cc
--- /dev/null
+++ b/apps/studio/src/ui/CommandSnippet.module.css
@@ -0,0 +1,32 @@
+.snippet {
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-2);
+ width: 100%;
+ min-width: 0;
+ padding: 4px 4px 4px var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ background: var(--rl-canvas);
+ box-shadow: inset 0 0 0 1px var(--rl-line);
+ text-align: left;
+}
+.label {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ white-space: nowrap;
+}
+.code {
+ flex: 1;
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+ color: var(--rl-text);
+ font-size: var(--rl-text-xs);
+ line-height: 28px;
+ scrollbar-width: none;
+}
+.prompt {
+ margin-right: 8px;
+ color: var(--rl-text-faint);
+ user-select: none;
+}
diff --git a/apps/studio/src/ui/CommandSnippet.tsx b/apps/studio/src/ui/CommandSnippet.tsx
new file mode 100644
index 0000000..34a06ed
--- /dev/null
+++ b/apps/studio/src/ui/CommandSnippet.tsx
@@ -0,0 +1,43 @@
+'use client';
+import { useState } from 'react';
+import { Button } from './Button';
+import styles from './CommandSnippet.module.css';
+
+/**
+ * A command in mono with a copy button. `prompt` is the glyph before it: `$`
+ * for a shell (default), `>` for something typed inside Claude Code.
+ */
+export function CommandSnippet({ command, label, prompt = '$' }: { command: string; label?: string; prompt?: '$' | '>' }) {
+ const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle');
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(command);
+ setState('copied');
+ } catch {
+ setState('failed');
+ }
+ setTimeout(() => setState('idle'), 1800);
+ };
+ return (
+
+ {label && {label} }
+
+
+ {prompt}
+
+ {command}
+
+ void copy()}
+ />
+
+ {state === 'copied' ? 'Copied to the clipboard' : state === 'failed' ? 'Could not copy' : ''}
+
+
+ );
+}
diff --git a/apps/studio/src/ui/ConfirmDialog.module.css b/apps/studio/src/ui/ConfirmDialog.module.css
new file mode 100644
index 0000000..8ecf4ef
--- /dev/null
+++ b/apps/studio/src/ui/ConfirmDialog.module.css
@@ -0,0 +1,34 @@
+.body {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--rl-space-3);
+ padding: var(--rl-space-5) var(--rl-space-5) var(--rl-space-4);
+}
+.icon {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--rl-radius-control);
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+ color: var(--rl-failure);
+}
+.title {
+ font-size: var(--rl-text-lg);
+ font-weight: 600;
+ line-height: 32px;
+}
+.impact {
+ margin-top: var(--rl-space-1);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+}
+.impact p + p {
+ margin-top: var(--rl-space-2);
+}
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--rl-space-2);
+ padding: var(--rl-space-3) var(--rl-space-5) var(--rl-space-4);
+}
diff --git a/apps/studio/src/ui/ConfirmDialog.tsx b/apps/studio/src/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..97b02fa
--- /dev/null
+++ b/apps/studio/src/ui/ConfirmDialog.tsx
@@ -0,0 +1,56 @@
+'use client';
+import { useId, useRef, type ReactNode } from 'react';
+import { Button } from './Button';
+import styles from './ConfirmDialog.module.css';
+import { Dialog } from './Dialog';
+import { Icon } from './Icon';
+
+/**
+ * Confirmation for a destructive action. `impact` must say what happens and
+ * what is kept. Focus starts on Cancel, so Enter never confirms by accident.
+ */
+export function ConfirmDialog({
+ open,
+ title,
+ impact,
+ confirmLabel,
+ onConfirm,
+ onCancel,
+ busy,
+}: {
+ open: boolean;
+ title: string;
+ impact: ReactNode;
+ confirmLabel: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ busy?: boolean;
+}) {
+ const id = useId();
+ const cancel = useRef(null);
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {impact}
+
+
+
+
+
+ Cancel
+
+
+ {confirmLabel}
+
+
+
+ );
+}
diff --git a/apps/studio/src/ui/ConnectionStatus.module.css b/apps/studio/src/ui/ConnectionStatus.module.css
new file mode 100644
index 0000000..0b253bb
--- /dev/null
+++ b/apps/studio/src/ui/ConnectionStatus.module.css
@@ -0,0 +1,59 @@
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 10px 0 8px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+ font-weight: 600;
+ white-space: nowrap;
+}
+.status[data-tone='live'] {
+ color: var(--rl-accent-text);
+ background: var(--rl-accent-soft);
+}
+.status[data-tone='err'] {
+ color: var(--rl-failure);
+ background: color-mix(in srgb, var(--rl-failure) 12%, transparent);
+}
+.status[data-tone='warn'] {
+ color: var(--rl-warning);
+ background: color-mix(in srgb, var(--rl-warning) 12%, transparent);
+}
+.status[data-tone='ok'] {
+ color: var(--rl-success);
+}
+.icon {
+ display: inline-grid;
+ place-items: center;
+ width: 12px;
+ height: 12px;
+}
+.text {
+ color: inherit;
+}
+.extra {
+ color: var(--rl-text-muted);
+ font-weight: 500;
+}
+/* Live: a restrained pulse, because events are arriving (rule zero). */
+.pulse {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 0 0 currentColor;
+ animation: pulse 1.8s var(--rl-ease-out) infinite;
+}
+@keyframes pulse {
+ 0% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent);
+ }
+ 70%,
+ 100% {
+ box-shadow: 0 0 0 6px transparent;
+ }
+}
diff --git a/apps/studio/src/ui/ConnectionStatus.tsx b/apps/studio/src/ui/ConnectionStatus.tsx
new file mode 100644
index 0000000..79c7ac6
--- /dev/null
+++ b/apps/studio/src/ui/ConnectionStatus.tsx
@@ -0,0 +1,17 @@
+import type { ConnectionView } from '../state/connection';
+import styles from './ConnectionStatus.module.css';
+import { GlyphIcon } from './Icon';
+
+/** Connecting · Live · Paused · Disconnected · Replaying · Recorded · Synthetic: always visible, always in words. */
+export function ConnectionStatus({ view, extra }: { view: ConnectionView; extra?: string }) {
+ return (
+
+
+ {view.state === 'live' ? : }
+
+ {view.text}
+ {extra && {extra} }
+ . {view.detail}
+
+ );
+}
diff --git a/apps/studio/src/ui/Dialog.module.css b/apps/studio/src/ui/Dialog.module.css
new file mode 100644
index 0000000..bd35c79
--- /dev/null
+++ b/apps/studio/src/ui/Dialog.module.css
@@ -0,0 +1,55 @@
+.scrim {
+ position: fixed;
+ inset: 0;
+ z-index: var(--rl-z-dialog);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: var(--rl-space-4);
+ background: var(--rl-scrim);
+ animation: fade var(--rl-dur-standard) var(--rl-ease-out);
+}
+.scrim[data-placement='top'] {
+ align-items: flex-start;
+ padding-top: min(14vh, 120px);
+}
+.scrim[data-placement='left'] {
+ justify-content: flex-start;
+ align-items: stretch;
+ padding: 0;
+}
+.scrim[data-placement='bottom'] {
+ align-items: flex-end;
+ padding: var(--rl-space-6) 0 0;
+}
+.scrim[data-placement='bottom'] .panel {
+ width: 100%;
+ max-height: 88dvh;
+ border-radius: var(--rl-radius-hero) var(--rl-radius-hero) 0 0;
+}
+.panel {
+ width: min(520px, 100%);
+ max-height: calc(100vh - 32px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+ box-shadow: var(--rl-shadow-pop), 0 0 0 1px var(--rl-line);
+ color: var(--rl-text);
+ animation: rise var(--rl-dur-standard) var(--rl-ease-out);
+}
+.panel:focus {
+ outline: none;
+}
+@keyframes fade {
+ from {
+ opacity: 0;
+ }
+}
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(6px) scale(0.99);
+ }
+}
diff --git a/apps/studio/src/ui/Dialog.tsx b/apps/studio/src/ui/Dialog.tsx
new file mode 100644
index 0000000..ff58ed2
--- /dev/null
+++ b/apps/studio/src/ui/Dialog.tsx
@@ -0,0 +1,93 @@
+'use client';
+import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
+import { createPortal } from 'react-dom';
+import styles from './Dialog.module.css';
+
+const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Modal dialog: rendered in a portal over a scrim, traps Tab focus, closes on
+ * Escape or a scrim click, and gives focus back to the opener on close.
+ */
+export function Dialog({
+ open,
+ onClose,
+ labelledBy,
+ describedBy,
+ children,
+ initialFocus,
+ placement = 'center',
+ className,
+}: {
+ open: boolean;
+ onClose: () => void;
+ labelledBy: string;
+ describedBy?: string;
+ children: ReactNode;
+ initialFocus?: RefObject;
+ placement?: 'center' | 'top' | 'left' | 'bottom';
+ className?: string;
+}) {
+ const [mounted, setMounted] = useState(false);
+ const panel = useRef(null);
+ // Latest callbacks without re-running the focus effect (which would steal focus on every render).
+ const closeRef = useRef(onClose);
+ closeRef.current = onClose;
+ const initialRef = useRef(initialFocus);
+ initialRef.current = initialFocus;
+ useEffect(() => setMounted(true), []);
+
+ useEffect(() => {
+ if (!open) return;
+ const opener = document.activeElement as HTMLElement | null;
+ const focusFirst = () => {
+ const target = initialRef.current?.current ?? panel.current?.querySelector(FOCUSABLE) ?? panel.current;
+ target?.focus();
+ };
+ const raf = requestAnimationFrame(focusFirst);
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ closeRef.current();
+ return;
+ }
+ if (e.key !== 'Tab' || !panel.current) return;
+ const items = [...panel.current.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
+ if (items.length === 0) return;
+ const first = items[0]!;
+ const last = items[items.length - 1]!;
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+ document.addEventListener('keydown', onKey, true);
+ return () => {
+ cancelAnimationFrame(raf);
+ document.removeEventListener('keydown', onKey, true);
+ opener?.focus?.();
+ };
+ }, [open]);
+
+ if (!open || !mounted) return null;
+ return createPortal(
+ e.target === e.currentTarget && onClose()}>
+
+ {children}
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/studio/src/ui/Icon.tsx b/apps/studio/src/ui/Icon.tsx
new file mode 100644
index 0000000..15a7bd0
--- /dev/null
+++ b/apps/studio/src/ui/Icon.tsx
@@ -0,0 +1,156 @@
+import type { CSSProperties } from 'react';
+
+/** 24×24 stroke icons (currentColor). `fill` paths are drawn solid. */
+const PATHS = {
+ overview: 'M4 4h6.5v6.5H4z M13.5 4H20v6.5h-6.5z M4 13.5h6.5V20H4z M13.5 13.5H20V20h-6.5z',
+ world: 'M12 2.8l8 4.6v9.2l-8 4.6-8-4.6V7.4z M12 12l8-4.6 M12 12v9.2 M12 12L4 7.4',
+ traces: 'M4 6h2 M9 6h11 M4 12h2 M9 12h11 M4 18h2 M9 18h7',
+ integrations: 'M9 3v5 M15 3v5 M6.5 8h11v3.5a5.5 5.5 0 0 1-11 0z M12 17v4',
+ settings: 'M4 7h9 M17 7h3 M15 5v4 M4 17h3 M11 17h9 M9 15v4',
+ search: 'M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13z M20 20l-4.2-4.2',
+ filter: 'M4 5h16l-6 7.2V19l-4 1.5v-8.3z',
+ play: 'M7.5 4.8v14.4L19 12z',
+ pause: 'M8.5 5v14 M15.5 5v14',
+ skipBack: 'M18 5.5v13L9 12z M6 5.5v13',
+ skipForward: 'M6 5.5v13L15 12z M18 5.5v13',
+ stop: 'M6.5 6.5h11v11h-11z',
+ check: 'M5 12.5l4.5 4.5L19 7.5',
+ x: 'M6.5 6.5l11 11 M17.5 6.5l-11 11',
+ alert: 'M12 3.8l9 15.7H3z M12 10v4.2 M12 17.2v.3',
+ xCircle: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M9.2 9.2l5.6 5.6 M14.8 9.2l-5.6 5.6',
+ checkCircle: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M8 12.3l2.8 2.8L16 9.6',
+ circle: 'M12 5a7 7 0 1 0 0 14a7 7 0 1 0 0-14z',
+ dot: 'M12 7.5a4.5 4.5 0 1 0 0 9a4.5 4.5 0 1 0 0-9z',
+ ring: 'M12 4a8 8 0 1 0 0 16a8 8 0 1 0 0-16z M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6z',
+ pulse: 'M3 12h4l2.5-6 5 12 2.5-6h4',
+ hourglass: 'M7 3.5h10 M7 20.5h10 M8 3.5c0 4.8 8 4.6 8 8.5s-8 3.7-8 8.5 M16 3.5c0 4.8-8 4.6-8 8.5s8 3.7 8 8.5',
+ clock: 'M12 3.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 1 0 0-17z M12 7.5V12l3 2',
+ ellipsis: 'M6 12h.01 M12 12h.01 M18 12h.01',
+ arrowRight: 'M5 12h14 M13 6l6 6-6 6',
+ chevronDown: 'M6 9l6 6 6-6',
+ chevronRight: 'M9.5 6l6 6-6 6',
+ lock: 'M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3',
+ key: 'M8.5 11a4.5 4.5 0 1 0 0 9a4.5 4.5 0 1 0 0-9z M11.7 12.3L20 4 M16.5 7.5l2.5 2.5',
+ file: 'M6.5 3h7.5l4 4v14h-11.5z M14 3v4h4',
+ message: 'M4 5h16v11H9.5L5 19.5V16H4z',
+ slash: 'M12 3.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 1 0 0-17z M6 18L18 6',
+ refresh: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3 M19.5 4.5v4h-4',
+ sun: 'M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8z M12 2.5v2 M12 19.5v2 M2.5 12h2 M19.5 12h2 M5.3 5.3l1.4 1.4 M17.3 17.3l1.4 1.4 M5.3 18.7l1.4-1.4 M17.3 6.7l1.4-1.4',
+ moon: 'M19.5 14.6A7.8 7.8 0 0 1 9.4 4.5a7.8 7.8 0 1 0 10.1 10.1z',
+ monitor: 'M3.5 5h17v11h-17z M8.5 20h7 M12 16v4',
+ copy: 'M9 9h11v11H9z M5.5 15H4V4h11v1.5',
+ external: 'M14 4h6v6 M20 4l-8.5 8.5 M18 14v6H4V6h6',
+ link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1 M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
+ agent: 'M5.5 9h13v10h-13z M12 5v4 M12 5h.01 M9.5 13.5v1.5 M14.5 13.5v1.5 M3 13v2 M21 13v2',
+ terminal: 'M4 5h16v14H4z M7.5 9.5l3 2.5-3 2.5 M12.5 15h4',
+ target: 'M12 3.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 1 0 0-17z M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6z',
+ sparkle: 'M12 3.5l2 5.5 5.5 2-5.5 2-2 5.5-2-5.5-5.5-2 5.5-2z',
+ table: 'M4 5h16v14H4z M4 10h16 M4 14.5h16 M9.5 10v9',
+ map: 'M3.5 6.5l5.5-2.5 6 2.5 5.5-2.5v13.5l-5.5 2.5-6-2.5-5.5 2.5z M9 4v13.5 M15 6.5V20',
+ menu: 'M4 7h16 M4 12h16 M4 17h16',
+ eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z M12 9.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5z',
+ eyeOff: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z M4 4l16 16',
+ inferred: 'M4.5 10c2.2-2.2 4.3-2.2 7.5 0s5.3 2.2 7.5 0 M4.5 15c2.2-2.2 4.3-2.2 7.5 0s5.3 2.2 7.5 0',
+ shield: 'M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z',
+ database: 'M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6 M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3',
+ graph: 'M6 4a2 2 0 1 1 0 4a2 2 0 1 1 0-4z M18 4a2 2 0 1 1 0 4a2 2 0 1 1 0-4z M12 16a2 2 0 1 1 0 4a2 2 0 1 1 0-4z M7 7.8l4 8.4 M17 7.8l-4 8.4 M8 6h8',
+ plus: 'M12 5v14 M5 12h14',
+ diamond: 'M12 3.5l8.5 8.5-8.5 8.5L3.5 12z',
+ halfCircle: 'M12 3.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 1 0 0-17z M12 3.5v17',
+ keyboard: 'M3.5 6h17v12h-17z M7 10h.01 M10.5 10h.01 M14 10h.01 M17 10h.01 M8 14.5h8',
+ command: 'M9 6.5a2.5 2.5 0 1 0-2.5 2.5H9V6.5z M15 6.5A2.5 2.5 0 1 1 17.5 9H15V6.5z M9 17.5A2.5 2.5 0 1 1 6.5 15H9v2.5z M15 17.5a2.5 2.5 0 1 0 2.5-2.5H15v2.5z M9 9h6v6H9z',
+ globe: 'M12 3.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 1 0 0-17z M3.5 12h17 M12 3.5c2.4 2.4 3.4 5.2 3.4 8.5s-1 6.1-3.4 8.5 M12 3.5C9.6 5.9 8.6 8.7 8.6 12s1 6.1 3.4 8.5',
+ pencil: 'M4.5 19.5l1-4.5L15.8 4.7a2 2 0 0 1 2.8 0l.7.7a2 2 0 0 1 0 2.8L9 18.5z M13.5 7l3.5 3.5',
+ flask: 'M9.5 3.5h5 M10.5 3.5v6L5 18.5a1.4 1.4 0 0 0 1.2 2h11.6a1.4 1.4 0 0 0 1.2-2l-5.5-9v-6 M7.4 14.5h9.2',
+ upload: 'M12 15.5V4 M7 8.5L12 3.5l5 5 M4.5 15v4.5h15V15',
+ bell: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2H5z M10 20.5h4',
+ layers: 'M12 4l8.5 4.5L12 13 3.5 8.5z M3.5 12.5L12 17l8.5-4.5 M3.5 16.5L12 21l8.5-4.5',
+ flag: 'M5.5 21V4 M5.5 4.5h11l-2 4 2 4h-11',
+ swap: 'M4 8h14 M14.5 4.5L18 8l-3.5 3.5 M20 16H6 M9.5 12.5L6 16l3.5 3.5',
+ coins: 'M9 5c3 0 5.5 1 5.5 2.3S12 9.6 9 9.6 3.5 8.6 3.5 7.3 6 5 9 5z M3.5 7.3v4.4c0 1.3 2.5 2.3 5.5 2.3 M3.5 11.7v4.4c0 1.3 2.5 2.3 5.5 2.3 M15 10.4c3 0 5.5 1 5.5 2.3s-2.5 2.3-5.5 2.3-5.5-1-5.5-2.3 2.5-2.3 5.5-2.3z M9.5 12.7v4.4c0 1.3 2.5 2.3 5.5 2.3s5.5-1 5.5-2.3v-4.4',
+} as const;
+
+export type IconName = keyof typeof PATHS;
+
+/** Icons drawn as solid shapes rather than strokes. */
+const FILLED: ReadonlySet = new Set(['play', 'stop', 'dot', 'skipBack', 'skipForward']);
+/** Icons that need a heavier stroke to read at small sizes. */
+const HEAVY: ReadonlySet = new Set(['ellipsis', 'pause']);
+
+export function Icon({ name, size = 16, className, style, label }: { name: IconName; size?: number; className?: string; style?: CSSProperties; label?: string }) {
+ const filled = FILLED.has(name);
+ return (
+
+
+
+ );
+}
+
+/**
+ * Status glyphs used by the label maps in state/view.ts → icons. Unknown
+ * glyphs fall back to the glyph itself, so new map entries still render.
+ */
+const GLYPHS: Record = {
+ '✓': 'check',
+ '✗': 'x',
+ '⚠': 'alert',
+ '●': 'dot',
+ '○': 'circle',
+ '◉': 'ring',
+ '◌': 'circle',
+ '⏳': 'hourglass',
+ '■': 'stop',
+ '⏸': 'pause',
+ '⚙': 'pulse',
+ '…': 'ellipsis',
+ '⋯': 'ellipsis',
+ '→': 'arrowRight',
+ '➜': 'arrowRight',
+ '🔒': 'lock',
+ '🔑': 'key',
+ '⊘': 'slash',
+ '▶': 'play',
+ '⏵': 'play',
+ '↻': 'refresh',
+ '◇': 'diamond',
+ '◆': 'diamond',
+ '◐': 'halfCircle',
+ '+': 'plus',
+ '✉': 'message',
+ '📄': 'file',
+ '•': 'dot',
+ '◫': 'layers',
+ '🔔': 'bell',
+ '⇄': 'swap',
+ '⚑': 'flag',
+ '¤': 'coins',
+ '⤳': 'agent',
+};
+
+export function iconForGlyph(glyph: string): IconName | null {
+ return GLYPHS[glyph] ?? null;
+}
+
+/** An icon for a status glyph, or the glyph as text when there is no icon for it. */
+export function GlyphIcon({ glyph, size = 14, className }: { glyph: string; size?: number; className?: string }) {
+ const name = iconForGlyph(glyph);
+ if (name) return ;
+ return (
+
+ {glyph}
+
+ );
+}
diff --git a/apps/studio/src/ui/Kbd.module.css b/apps/studio/src/ui/Kbd.module.css
new file mode 100644
index 0000000..2327280
--- /dev/null
+++ b/apps/studio/src/ui/Kbd.module.css
@@ -0,0 +1,21 @@
+.kbd {
+ display: inline-flex;
+ gap: 3px;
+ vertical-align: middle;
+}
+.kbd kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 4px;
+ border-radius: 4px;
+ background: var(--rl-surface-2);
+ box-shadow: inset 0 -1px 0 var(--rl-line-strong);
+ color: var(--rl-text-muted);
+ font-family: var(--rl-font-sans);
+ font-size: var(--rl-text-2xs);
+ font-weight: 500;
+ line-height: 1;
+}
diff --git a/apps/studio/src/ui/Kbd.tsx b/apps/studio/src/ui/Kbd.tsx
new file mode 100644
index 0000000..9324043
--- /dev/null
+++ b/apps/studio/src/ui/Kbd.tsx
@@ -0,0 +1,20 @@
+import styles from './Kbd.module.css';
+
+/** Keyboard shortcut hint: . */
+export function Kbd({ keys, label }: { keys: readonly string[]; label?: string }) {
+ return (
+
+ {keys.map((k, i) => (
+
+ {k}
+
+ ))}
+
+ );
+}
+
+/** ⌘ on Apple platforms, Ctrl elsewhere (client only; falls back to Ctrl). */
+export function modKey(): string {
+ if (typeof navigator === 'undefined') return 'Ctrl';
+ return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘' : 'Ctrl';
+}
diff --git a/apps/studio/src/ui/Popover.module.css b/apps/studio/src/ui/Popover.module.css
new file mode 100644
index 0000000..ded8a72
--- /dev/null
+++ b/apps/studio/src/ui/Popover.module.css
@@ -0,0 +1,33 @@
+.root {
+ position: relative;
+ display: inline-flex;
+}
+.trigger[data-active='true'] {
+ background: var(--rl-accent-soft);
+ color: var(--rl-accent-text);
+}
+.panel {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ z-index: var(--rl-z-overlay);
+ min-width: 220px;
+ max-width: 320px;
+ max-height: 320px;
+ overflow-y: auto;
+ padding: var(--rl-space-2);
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface);
+ box-shadow: var(--rl-shadow-pop), 0 0 0 1px var(--rl-line);
+ animation: pop var(--rl-dur-standard) var(--rl-ease-out);
+}
+.panel[data-align='end'] {
+ left: auto;
+ right: 0;
+}
+@keyframes pop {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+}
diff --git a/apps/studio/src/ui/Popover.tsx b/apps/studio/src/ui/Popover.tsx
new file mode 100644
index 0000000..28aca83
--- /dev/null
+++ b/apps/studio/src/ui/Popover.tsx
@@ -0,0 +1,60 @@
+'use client';
+import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
+import { buttonClass } from './Button';
+import { Icon, type IconName } from './Icon';
+import styles from './Popover.module.css';
+
+/**
+ * Disclosure button with a popover panel (used for filter menus). Escape or a
+ * click outside closes it and focus returns to the button.
+ */
+export function Popover({ label, icon, active, children, align = 'start' }: { label: ReactNode; icon?: IconName; active?: boolean; children: ReactNode; align?: 'start' | 'end' }) {
+ const [open, setOpen] = useState(false);
+ const id = useId();
+ const root = useRef(null);
+ const button = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (!root.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ setOpen(false);
+ button.current?.focus();
+ }
+ };
+ document.addEventListener('mousedown', onDown);
+ document.addEventListener('keydown', onKey, true);
+ requestAnimationFrame(() => root.current?.querySelector(`#${CSS.escape(id)} input, #${CSS.escape(id)} button`)?.focus());
+ return () => {
+ document.removeEventListener('mousedown', onDown);
+ document.removeEventListener('keydown', onKey, true);
+ };
+ }, [open, id]);
+
+ return (
+
+
setOpen((o) => !o)}
+ >
+ {icon && }
+ {label}
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/apps/studio/src/ui/SegmentedControl.module.css b/apps/studio/src/ui/SegmentedControl.module.css
new file mode 100644
index 0000000..5489938
--- /dev/null
+++ b/apps/studio/src/ui/SegmentedControl.module.css
@@ -0,0 +1,57 @@
+.group {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ border-radius: calc(var(--rl-radius-control) + 2px);
+ background: var(--rl-surface-2);
+}
+.group[data-glass] {
+ background: var(--rl-glass);
+ backdrop-filter: blur(12px) saturate(1.2);
+ box-shadow: 0 0 0 1px var(--rl-glass-line);
+}
+.option {
+ position: relative;
+ display: inline-flex;
+}
+.input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ margin: 0;
+ cursor: pointer;
+}
+.face {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ transition:
+ background-color var(--rl-dur-instant) var(--rl-ease-out),
+ color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.group[data-size='sm'] .face {
+ height: 24px;
+ padding: 0 9px;
+ font-size: var(--rl-text-xs);
+}
+.option:hover .face {
+ color: var(--rl-text);
+}
+.input:checked + .face {
+ background: var(--rl-surface);
+ color: var(--rl-text);
+ box-shadow: var(--rl-shadow-card), 0 0 0 1px var(--rl-line);
+}
+.group[data-glass] .input:checked + .face {
+ background: var(--rl-surface-2);
+}
+.input:focus-visible + .face {
+ box-shadow: var(--rl-focus-ring);
+}
diff --git a/apps/studio/src/ui/SegmentedControl.tsx b/apps/studio/src/ui/SegmentedControl.tsx
new file mode 100644
index 0000000..9853d1f
--- /dev/null
+++ b/apps/studio/src/ui/SegmentedControl.tsx
@@ -0,0 +1,46 @@
+'use client';
+import { useId } from 'react';
+import { Icon, type IconName } from './Icon';
+import styles from './SegmentedControl.module.css';
+
+export interface Segment {
+ value: T;
+ label: string;
+ icon?: IconName;
+ /** Visually hide the label (it stays the accessible name). */
+ iconOnly?: boolean;
+ title?: string;
+}
+
+/** A single choice from a few options: native radios (arrow keys move the choice). */
+export function SegmentedControl({
+ label,
+ options,
+ value,
+ onChange,
+ size = 'md',
+ glass,
+}: {
+ label: string;
+ options: readonly Segment[];
+ value: T;
+ onChange: (value: T) => void;
+ size?: 'sm' | 'md';
+ /** Floating over the 3D world. */
+ glass?: boolean;
+}) {
+ const name = useId();
+ return (
+
+ {options.map((o) => (
+
+ onChange(o.value)} className={styles.input} />
+
+ {o.icon && }
+ {o.label}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/studio/src/ui/Sidebar.module.css b/apps/studio/src/ui/Sidebar.module.css
new file mode 100644
index 0000000..1d157b0
--- /dev/null
+++ b/apps/studio/src/ui/Sidebar.module.css
@@ -0,0 +1,116 @@
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ padding: var(--rl-space-4) var(--rl-space-3);
+ gap: var(--rl-space-5);
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 2px var(--rl-space-2);
+ border-radius: var(--rl-radius-control);
+}
+.mark {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 9px;
+ background: var(--rl-surface-2);
+ color: var(--rl-accent-text);
+ flex: none;
+}
+.wordmark {
+ font-size: var(--rl-text-md);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+}
+.product {
+ color: var(--rl-text-muted);
+ font-weight: 500;
+}
+.items {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--rl-space-3);
+ height: 36px;
+ padding: 0 var(--rl-space-3);
+ border-radius: var(--rl-radius-control);
+ color: var(--rl-text-muted);
+ font-weight: 500;
+ transition:
+ background-color var(--rl-dur-instant) var(--rl-ease-out),
+ color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.item:hover {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+.item[aria-current='page'] {
+ background: var(--rl-surface-2);
+ color: var(--rl-text);
+}
+.item[aria-current='page']::before {
+ content: '';
+ position: absolute;
+ left: -12px;
+ top: 9px;
+ bottom: 9px;
+ width: 3px;
+ border-radius: 0 3px 3px 0;
+ background: var(--rl-accent-text);
+}
+.item[aria-current='page'] svg {
+ color: var(--rl-accent-text);
+}
+.label {
+ white-space: nowrap;
+}
+.extra {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ margin: 0 calc(-1 * var(--rl-space-3));
+ padding: 0 var(--rl-space-3);
+}
+.footer {
+ margin-top: auto;
+}
+
+/* Rail: icons only (labels stay available to assistive tech and as tooltips). */
+.sidebar[data-rail] .wordmark,
+.sidebar[data-rail] .label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
+.sidebar[data-rail] {
+ padding: var(--rl-space-4) var(--rl-space-2);
+ align-items: center;
+}
+.sidebar[data-rail] .item {
+ justify-content: center;
+ width: 44px;
+ padding: 0;
+}
+.sidebar[data-rail] .item[aria-current='page']::before {
+ left: -10px;
+}
+.sidebar[data-rail] .brand {
+ padding: 2px;
+}
+.sidebar[data-rail] .footer {
+ width: 100%;
+}
diff --git a/apps/studio/src/ui/Sidebar.tsx b/apps/studio/src/ui/Sidebar.tsx
new file mode 100644
index 0000000..dd277bf
--- /dev/null
+++ b/apps/studio/src/ui/Sidebar.tsx
@@ -0,0 +1,64 @@
+'use client';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { Icon, type IconName } from './Icon';
+import styles from './Sidebar.module.css';
+
+export interface NavItem {
+ href: string;
+ label: string;
+ icon: IconName;
+ active: boolean;
+}
+
+/** Primary navigation: brand, areas (only those that work), extra sections, footer. */
+export function Sidebar({
+ items,
+ children,
+ footer,
+ rail,
+ onNavigate,
+}: {
+ items: readonly NavItem[];
+ children?: ReactNode;
+ footer?: ReactNode;
+ /** Icons only (labels stay as tooltips and accessible names). */
+ rail?: boolean;
+ onNavigate?: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ Runloop Studio
+
+
+
+
+ {items.map((item) => (
+
+
+
+ {item.label}
+
+
+ ))}
+
+
+ {children &&
{children}
}
+ {footer &&
{footer}
}
+
+ );
+}
diff --git a/apps/studio/src/ui/SplitPanels.module.css b/apps/studio/src/ui/SplitPanels.module.css
new file mode 100644
index 0000000..2b8f79e
--- /dev/null
+++ b/apps/studio/src/ui/SplitPanels.module.css
@@ -0,0 +1,51 @@
+.group {
+ min-width: 0;
+ min-height: 0;
+}
+.panel {
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.handle {
+ position: relative;
+ flex: none;
+ display: grid;
+ place-items: center;
+ outline: none;
+}
+.handle[data-orientation='horizontal'] {
+ width: 9px;
+ margin: 0 -4px;
+ z-index: 2;
+ cursor: col-resize;
+}
+.handle[data-orientation='vertical'] {
+ height: 9px;
+ margin: -4px 0;
+ z-index: 2;
+ cursor: row-resize;
+}
+.grip {
+ border-radius: 2px;
+ background: transparent;
+ transition: background-color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.handle[data-orientation='horizontal'] .grip {
+ width: 2px;
+ height: 100%;
+}
+.handle[data-orientation='vertical'] .grip {
+ width: 100%;
+ height: 2px;
+}
+.handle:hover .grip,
+.handle[data-separator='active'] .grip,
+.handle:focus-visible .grip {
+ background: var(--rl-accent-text);
+}
+.handle:focus-visible {
+ box-shadow: none;
+}
diff --git a/apps/studio/src/ui/SplitPanels.tsx b/apps/studio/src/ui/SplitPanels.tsx
new file mode 100644
index 0000000..0082d8f
--- /dev/null
+++ b/apps/studio/src/ui/SplitPanels.tsx
@@ -0,0 +1,36 @@
+'use client';
+import type { ReactNode } from 'react';
+import { Group, Panel, Separator, useDefaultLayout, type LayoutStorage, type PanelProps } from 'react-resizable-panels';
+import { safeStorage } from '../state/prefs';
+import styles from './SplitPanels.module.css';
+
+const storage: LayoutStorage = safeStorage;
+
+/** Resizable split whose sizes persist per viewer (localStorage, key runloop.layout.). */
+export function SplitGroup({ id, orientation = 'horizontal', children, className }: { id: string; orientation?: 'horizontal' | 'vertical'; children: ReactNode; className?: string }) {
+ const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: `runloop.layout.${id}`, storage, onlySaveAfterUserInteractions: true });
+ return (
+
+ {children}
+
+ );
+}
+
+export function SplitPanel({ className, ...props }: PanelProps) {
+ return ;
+}
+
+/** Keyboard-operable divider (arrow keys resize; double-click resets). */
+export function SplitHandle({ label, orientation = 'horizontal' }: { label: string; orientation?: 'horizontal' | 'vertical' }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/studio/src/ui/States.module.css b/apps/studio/src/ui/States.module.css
new file mode 100644
index 0000000..aa77ca2
--- /dev/null
+++ b/apps/studio/src/ui/States.module.css
@@ -0,0 +1,123 @@
+.skeleton {
+ display: inline-block;
+ border-radius: 4px;
+ background: linear-gradient(90deg, var(--rl-surface-2) 0%, var(--rl-surface-3) 50%, var(--rl-surface-2) 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.4s ease-in-out infinite;
+}
+@keyframes shimmer {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: -100% 0;
+ }
+}
+.rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-4);
+ padding: var(--rl-space-4);
+}
+.row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--rl-space-3);
+}
+.rowText {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--rl-space-2);
+ max-width: 480px;
+ margin: auto;
+ padding: var(--rl-space-8) var(--rl-space-6);
+ text-align: center;
+}
+.state[data-compact] {
+ padding: var(--rl-space-6) var(--rl-space-4);
+}
+.icon {
+ display: grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ margin-bottom: var(--rl-space-2);
+ border-radius: var(--rl-radius-card);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+}
+.state[data-compact] .icon {
+ width: 36px;
+ height: 36px;
+}
+.state[data-tone='err'] .icon {
+ background: color-mix(in srgb, var(--rl-failure) 14%, transparent);
+ color: var(--rl-failure);
+}
+.text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rl-space-2);
+}
+.state[data-inline] {
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--rl-space-3);
+ max-width: none;
+ margin: 0;
+ padding: var(--rl-space-1) 0;
+ text-align: left;
+}
+.state[data-inline] .icon {
+ margin: 0;
+ flex: none;
+}
+.state[data-inline] .text {
+ gap: 2px;
+}
+.state[data-inline] .title {
+ font-size: var(--rl-text-sm);
+}
+.state[data-inline] .actions {
+ margin: 0 0 0 auto;
+}
+.title {
+ font-size: var(--rl-text-lg);
+ font-weight: 600;
+ letter-spacing: -0.005em;
+}
+.body {
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+ overflow-wrap: anywhere;
+}
+.body p + p {
+ margin-top: var(--rl-space-2);
+}
+.saved {
+ max-width: 420px;
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-xs);
+}
+.saved svg {
+ display: inline-block;
+ margin-right: 6px;
+ vertical-align: -2px;
+}
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--rl-space-2);
+ margin-top: var(--rl-space-3);
+}
diff --git a/apps/studio/src/ui/States.tsx b/apps/studio/src/ui/States.tsx
new file mode 100644
index 0000000..f99ea8d
--- /dev/null
+++ b/apps/studio/src/ui/States.tsx
@@ -0,0 +1,110 @@
+import type { CSSProperties, ReactNode } from 'react';
+import { Button } from './Button';
+import { CommandSnippet } from './CommandSnippet';
+import { Icon, type IconName } from './Icon';
+import styles from './States.module.css';
+
+/** A placeholder block while data loads (announced once by its container's aria-busy). */
+export function Skeleton({ width = '100%', height = 14, radius, className }: { width?: number | string; height?: number | string; radius?: number; className?: string }) {
+ return (
+
+ );
+}
+
+/** Rows of skeleton lines in the shape of a list. */
+export function SkeletonRows({ rows = 6, label = 'Loading' }: { rows?: number; label?: string }) {
+ return (
+
+ {Array.from({ length: rows }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+/** Nothing here yet: say why, and teach the next useful step. */
+export function EmptyState({
+ icon = 'sparkle',
+ title,
+ children,
+ actions,
+ compact,
+ inline,
+}: {
+ icon?: IconName;
+ title: string;
+ children?: ReactNode;
+ actions?: ReactNode;
+ compact?: boolean;
+ /** Left-aligned row, for empty sections inside a card. */
+ inline?: boolean;
+}) {
+ return (
+
+
+
+
+
+
{title}
+ {children &&
{children}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
+
+/** Something failed: what failed, whether data was saved, and how to retry. */
+export function ErrorState({
+ title,
+ children,
+ saved,
+ command,
+ onRetry,
+ retryLabel = 'Retry',
+ compact,
+}: {
+ title: string;
+ children?: ReactNode;
+ /** Whether recorded data is safe (shown as its own line). */
+ saved?: ReactNode;
+ /** Exact command that fixes it, with a copy button. */
+ command?: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ compact?: boolean;
+}) {
+ return (
+
+
+
+
+
{title}
+ {children &&
{children}
}
+ {command &&
}
+ {saved && (
+
+
+ {saved}
+
+ )}
+ {onRetry && (
+
+
+ {retryLabel}
+
+
+ )}
+
+ );
+}
diff --git a/apps/studio/src/ui/Tabs.module.css b/apps/studio/src/ui/Tabs.module.css
new file mode 100644
index 0000000..efd5d56
--- /dev/null
+++ b/apps/studio/src/ui/Tabs.module.css
@@ -0,0 +1,62 @@
+.tabs {
+ display: flex;
+ gap: 2px;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.tab {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 36px;
+ padding: 0 var(--rl-space-3);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ border-radius: var(--rl-radius-sm);
+ transition: color var(--rl-dur-instant) var(--rl-ease-out);
+}
+.tabs[data-size='sm'] .tab {
+ height: 30px;
+ padding: 0 10px;
+ font-size: var(--rl-text-xs);
+}
+.tab:hover {
+ color: var(--rl-text);
+}
+/* The tab strip scrolls sideways, which clips an outer ring: draw the focus ring inside the tab. */
+.tab:focus-visible {
+ outline: none;
+ box-shadow: inset 0 0 0 2px var(--rl-focus);
+}
+.tab[aria-selected='true'] {
+ color: var(--rl-text);
+}
+/* Selected tab: a lime underline (the selection accent), not a filled box. */
+.tab::after {
+ content: '';
+ position: absolute;
+ left: 10px;
+ right: 10px;
+ bottom: 0;
+ height: 2px;
+ border-radius: 2px;
+ background: transparent;
+ transition: background-color var(--rl-dur-standard) var(--rl-ease-out);
+}
+.tab[aria-selected='true']::after {
+ background: var(--rl-accent-text);
+}
+.count {
+ min-width: 18px;
+ padding: 0 5px;
+ border-radius: var(--rl-radius-pill);
+ background: var(--rl-surface-2);
+ color: var(--rl-text-muted);
+ font-size: var(--rl-text-2xs);
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/apps/studio/src/ui/Tabs.tsx b/apps/studio/src/ui/Tabs.tsx
new file mode 100644
index 0000000..e61f08c
--- /dev/null
+++ b/apps/studio/src/ui/Tabs.tsx
@@ -0,0 +1,67 @@
+'use client';
+import { useRef, type KeyboardEvent, type ReactNode } from 'react';
+import styles from './Tabs.module.css';
+
+export interface TabItem {
+ id: T;
+ label: ReactNode;
+ /** Count shown after the label (tabular). */
+ count?: number;
+}
+
+/**
+ * WAI-ARIA tabs with a roving tabindex (←/→, Home, End). The caller renders
+ * the panel with id `${idPrefix}-panel` and aria-labelledby `${idPrefix}-tab-${value}`.
+ */
+export function Tabs({
+ items,
+ value,
+ onChange,
+ label,
+ idPrefix,
+ size = 'md',
+}: {
+ items: readonly TabItem[];
+ value: T;
+ onChange: (id: T) => void;
+ label: string;
+ idPrefix: string;
+ size?: 'sm' | 'md';
+}) {
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
+ const onKey = (e: KeyboardEvent) => {
+ const i = items.findIndex((t) => t.id === value);
+ let next = -1;
+ if (e.key === 'ArrowRight') next = (i + 1) % items.length;
+ else if (e.key === 'ArrowLeft') next = (i - 1 + items.length) % items.length;
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = items.length - 1;
+ if (next < 0) return;
+ e.preventDefault();
+ onChange(items[next]!.id);
+ refs.current[next]?.focus();
+ };
+ return (
+