Merge origin/main into settings-driven LLM catalog branch

This commit is contained in:
Bryan Helmkamp 2026-05-11 09:42:18 -04:00
commit b32b9377a3
No known key found for this signature in database
695 changed files with 60841 additions and 15400 deletions

View file

@ -1,5 +1,5 @@
[alias]
dev = "run --package fabro-dev --"
dev = "run --package fabro-dev --features dev --"
t = "test -- --format terse"
[env]

View file

@ -1 +1 @@
061ccc673b0409778edb759bb4fbdd1bee923247
9a3ab8bbba2e71d72ff7703a68e9892e8ce94d8d

View file

@ -1 +1 @@
31cbdb5c316e0e128d935371989afef0d4714209
2f10ee39afe6bd9a1c1df987da52fb82c91b422e

View file

@ -0,0 +1,12 @@
digraph GhList {
graph [goal="List open PRs and issues via the gh CLI to verify GITHUB_TOKEN injection"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
list_prs [label="List PRs", shape=parallelogram, script="gh pr list --state open --limit 50 2>&1"]
list_issues [label="List Issues", shape=parallelogram, script="gh issue list --state open --limit 50 2>&1"]
start -> list_prs -> list_issues -> exit
}

View file

@ -0,0 +1,5 @@
_version = 1
[run.integrations.github.permissions]
pull_requests = "read"
issues = "read"

View file

@ -1,7 +1,5 @@
_version = 1
[server.integrations.github]
[server.integrations.github.permissions]
pull_requests = "read"
[run.integrations.github.permissions]
issues = "read"
pull_requests = "write"

View file

@ -1,7 +1,5 @@
_version = 1
[server.integrations.github]
[server.integrations.github.permissions]
[run.integrations.github.permissions]
pull_requests = "read"
issues = "read"
pull_requests = "write"

View file

@ -0,0 +1,60 @@
digraph Interview {
graph [goal="Run a progressive human interview and summarize the answers"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
yes_no [
shape=hexagon,
label="Is this interview workflow easy to follow so far?",
question_type="yes_no"
]
confirmation [
shape=hexagon,
label="Confirm that you want to continue into more structured questions.",
question_type="confirmation"
]
multiple_choice [
shape=hexagon,
label="Which theme should be the center of the final summary?",
question_type="multiple_choice"
]
multi_select [
shape=hexagon,
label="Which supporting areas should the final summary emphasize?",
question_type="multi_select"
]
freeform [
shape=hexagon,
label="Add any final context, constraints, or nuance for the summary.",
question_type="freeform"
]
summarize [
shape=tab,
label="Summarize Interview",
fidelity="summary:high",
prompt="Summarize the full human interview. Include each question and answer in order, then synthesize the user's priorities, constraints, and open questions. Use the human.gate.<node>.question and human.gate.<node>.answer context keys when present. Do not invent missing answers."
]
start -> yes_no
yes_no -> confirmation [label="[Y] Yes"]
yes_no -> confirmation [label="[N] No"]
confirmation -> multiple_choice [label="[Y] Continue"]
confirmation -> multiple_choice [label="[N] Stop after this, but still summarize"]
multiple_choice -> multi_select [label="[G] Goals"]
multiple_choice -> multi_select [label="[R] Risks"]
multiple_choice -> multi_select [label="[T] Tradeoffs"]
multiple_choice -> multi_select [label="[Q] Open questions"]
multi_select -> freeform [label="[S] Success criteria"]
multi_select -> freeform [label="[B] Blockers"]
multi_select -> freeform [label="[D] Decisions needed"]
multi_select -> freeform [label="[N] Next steps"]
freeform -> summarize [freeform=true]
summarize -> exit
}

View file

@ -0,0 +1 @@
_version = 1

View file

@ -0,0 +1,11 @@
digraph Sleeper {
graph [goal="Sleep long enough for a human to practice interrupts and steering"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
nap [label="Nap", timeout="360s", prompt="Run exactly one shell command using the Bash tool: `sleep 300`, with the tool `timeout_ms` parameter set to 310000 or higher. Wait for it to return. Do not run any other commands, do not read or edit any files, do not check the environment first. After `sleep 300` returns, reply with the single word 'done' and stop."]
start -> nap -> exit
}

View file

@ -0,0 +1 @@
_version = 1

View file

@ -59,7 +59,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- run: cd apps/fabro-web && bun test
- run: cd apps/fabro-web && bun run test
build:
name: Build

3
.gitignore vendored
View file

@ -2,7 +2,8 @@ target
.env
.entire
node_modules
apps/fabro-web/dist/
apps/fabro-web/dist
apps/fabro-web/.dist-builds/
lib/crates/fabro-spa/assets/*
!lib/crates/fabro-spa/assets/.gitkeep
tmp

View file

@ -109,7 +109,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
### Rust crates (`lib/crates/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, retros, and human-in-the-loop interactions
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
@ -180,7 +180,6 @@ Never run `cargo insta accept` without first checking what's pending — it acce
## Testing workflows
- `fabro run <name>` — run a workflow by name (resolves `.fabro/workflows/<name>/workflow.toml`), e.g. `fabro run repl`
- Use `--no-retro` to skip the retro step and finish faster
- `#[e2e_test(twin, live("VAR"))]` — dual-mode test that runs against twin-openai or real API. `#[e2e_test(twin)]` for twin-only tests (e.g., scripted failures). `#[e2e_test(live("VAR"))]` for live-only tests requiring secrets. `#[e2e_test()]` for sandbox tests with no API deps. Behavior is controlled by `FABRO_TEST_MODE` (`live`, `strict`; default is `twin`), and `cargo nextest run --profile e2e ...` implies `strict`. Use `fabro_test::e2e_openai!()` in twin/dual-mode tests to get `(base_url, api_key)`.
- Local test HTTP clients must use `.no_proxy()`. Prefer shared helpers like `fabro_test::test_http_client()` or crate-local equivalents instead of `reqwest::Client::new()`, bare `Client::builder().build()`, or `reqwest::get(...)`.
- This is not cosmetic: macOS proxy discovery adds hidden startup overhead to repeated localhost reqwest clients and can surface as misleading nextest timeouts.

162
Cargo.lock generated
View file

@ -373,6 +373,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"axum-macros",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
@ -391,8 +392,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@ -1208,7 +1211,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -1222,7 +1225,7 @@ dependencies = [
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
@ -1235,14 +1238,14 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"url",
]
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -1536,7 +1539,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1575,7 +1578,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1596,7 +1599,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1618,9 +1621,13 @@ dependencies = [
"tokio",
]
[[package]]
name = "fabro-build-support"
version = "0.230.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1636,7 +1643,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -1657,6 +1664,7 @@ dependencies = [
"fabro-agent",
"fabro-api",
"fabro-auth",
"fabro-build-support",
"fabro-checkpoint",
"fabro-client",
"fabro-config",
@ -1675,12 +1683,12 @@ dependencies = [
"fabro-oauth",
"fabro-proc",
"fabro-redact",
"fabro-retro",
"fabro-sandbox",
"fabro-server",
"fabro-static",
"fabro-store",
"fabro-telemetry",
"fabro-template",
"fabro-test",
"fabro-types",
"fabro-util",
@ -1698,6 +1706,7 @@ dependencies = [
"jsonwebtoken",
"libc",
"miette",
"nix 0.30.1",
"object_store",
"openssl",
"paste",
@ -1732,7 +1741,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -1761,7 +1770,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -1788,7 +1797,7 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
@ -1803,14 +1812,13 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"csv",
"fabro-cli",
"fabro-config",
"fabro-options-metadata",
"quick-xml 0.36.2",
@ -1823,7 +1831,7 @@ dependencies = [
[[package]]
name = "fabro-devcontainer"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-static",
@ -1840,7 +1848,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -1854,7 +1862,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -1876,7 +1884,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -1890,7 +1898,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -1914,7 +1922,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"fabro-static",
"http",
@ -1924,7 +1932,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -1939,7 +1947,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -1954,7 +1962,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1986,7 +1994,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -1997,7 +2005,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"fabro-config",
@ -2013,7 +2021,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"fabro-static",
"insta",
@ -2024,7 +2032,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2046,7 +2054,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2054,7 +2062,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2063,7 +2071,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2077,28 +2085,9 @@ dependencies = [
"url",
]
[[package]]
name = "fabro-retro"
version = "0.223.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
"fabro-agent",
"fabro-dump",
"fabro-llm",
"fabro-store",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "fabro-sandbox"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2117,11 +2106,13 @@ dependencies = [
"fabro-types",
"fabro-util",
"futures",
"futures-util",
"git2",
"glob",
"httpmock",
"rand 0.9.4",
"reqwest-middleware",
"rustls",
"serde",
"serde_json",
"shlex",
@ -2130,6 +2121,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-util",
"toml 0.8.23",
"tracing",
@ -2138,7 +2130,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2153,6 +2145,7 @@ dependencies = [
"fabro-agent",
"fabro-api",
"fabro-auth",
"fabro-build-support",
"fabro-config",
"fabro-github",
"fabro-graphviz",
@ -2165,7 +2158,6 @@ dependencies = [
"fabro-model",
"fabro-proc",
"fabro-redact",
"fabro-retro",
"fabro-sandbox",
"fabro-slack",
"fabro-spa",
@ -2219,7 +2211,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -2232,7 +2224,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
@ -2240,18 +2232,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -2278,7 +2270,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2304,7 +2296,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"fabro-util",
@ -2316,7 +2308,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"assert_cmd",
"axum",
@ -2339,7 +2331,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2353,7 +2345,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -2374,7 +2366,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -2394,7 +2386,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"fabro-graphviz",
"fabro-model",
@ -2404,7 +2396,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"chrono",
"fabro-types",
@ -2416,7 +2408,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2442,7 +2434,6 @@ dependencies = [
"fabro-mcp",
"fabro-model",
"fabro-redact",
"fabro-retro",
"fabro-sandbox",
"fabro-static",
"fabro-store",
@ -6873,10 +6864,22 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.26.2",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@ -7118,9 +7121,26 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.4",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "twin-github"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -7139,7 +7159,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -5,7 +5,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.223.0-nightly.0"
version = "0.230.0-nightly.0"
license = "MIT"
[workspace.dependencies]
@ -68,8 +68,8 @@ twin-openai = { path = "test/twin/openai" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fa4870f", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fa4870f", package = "daytona-api-client" }
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-api-client" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -41,7 +41,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens
- **Run agents 24/7** — Fabro's API server queues and executes runs continuously. Close your laptop — workflows keep running and results are waiting when you return.
- **Scale infinitely** — Move execution off your laptop and into cloud sandboxes. Run as many concurrent workflows as your infrastructure allows.
- **Guarantee code quality** — Layer deterministic verifications — test suites, linters, type checkers, LLM-as-judge — into your workflow graph. Failures trigger fix loops automatically.
- **Achieve compounding engineering** — Automatic retrospectives after every run feed a continuous improvement loop. Your workflows get better over time, not just your code.
- **Inspect every run** — Query durable event streams, checkpoints, conclusions, and stage outputs to understand what happened and improve the workflow.
- **Specify in natural language** — Define requirements as natural-language specs and let Fabro generate — and regenerate — implementations that conform to them.
---
@ -56,7 +56,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens
| ☁️ | Cloud sandboxes | Run agents in isolated Daytona cloud VMs with snapshot-based setup, network controls, and automatic cleanup |
| 🔌 | SSH access and preview links | Shell into running sandboxes with `fabro sandbox ssh` and expose ports with `fabro sandbox preview` for live debugging |
| 🌲 | Git checkpointing | Every stage commits code changes and execution metadata to Git branches. Resume, revert, or trace any change |
| 📊 | Automatic retros | Each run generates a retrospective with cost, duration, files touched, and an LLM-written narrative |
| 📊 | Run observability | Durable events, checkpoints, conclusions, and stage outputs make every run inspectable and exportable |
| ⚡ | Comprehensive API | REST API with SSE event streaming and a React web UI. Run workflows programmatically or as a service |
| 🦀 | Single binary, no runtime | One compiled Rust executable with zero dependencies. No Python, no Node, no Docker required |
| ⚖️ | Open source (MIT) | Full source code, no vendor lock-in. Self-host, fork, or extend to fit your workflow |
@ -104,7 +104,7 @@ Fabro ships with [comprehensive documentation](https://docs.fabro.sh) covering e
- [**Getting Started**](https://docs.fabro.sh/getting-started/introduction) -- Installation, first workflow, and why Fabro exists
- [**Defining Workflows**](https://docs.fabro.sh/workflows/stages-and-nodes) -- Node types, transitions, variables, stylesheets, and human gates
- [**Executing Workflows**](https://docs.fabro.sh/execution/run-configuration) -- Run configuration, sandboxes, checkpoints, retros, and failure handling
- [**Executing Workflows**](https://docs.fabro.sh/execution/run-configuration) -- Run configuration, sandboxes, checkpoints, observability, and failure handling
- [**Tutorials**](https://docs.fabro.sh/tutorials/hello-world) -- Step-by-step guides from hello world to parallel multi-model ensembles
- [**API Reference**](https://docs.fabro.sh/api-reference/overview) -- Full OpenAPI spec with authentication, SSE events, and client SDKs

View file

@ -1,3 +1,4 @@
@import "@xterm/xterm/css/xterm.css";
@import "tailwindcss";
@plugin "@tailwindcss/typography";

View file

@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { SWRConfig } from "swr";
import { EditableRunTitle } from "./editable-run-title";
import { ToastProvider } from "./toast";
import { generatedAxios } from "../lib/api-client";
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
<ToastProvider>{node}</ToastProvider>
</SWRConfig>,
);
});
return tree!;
}
function instanceText(instance: TestRenderer.ReactTestInstance): string {
const parts: string[] = [];
for (const child of instance.children) {
if (typeof child === "string") parts.push(child);
else parts.push(instanceText(child));
}
return parts.join("");
}
function findEditButton(
tree: TestRenderer.ReactTestRenderer,
): TestRenderer.ReactTestInstance {
return tree.root.findByProps({ "aria-label": "Edit run title" });
}
function findInput(
tree: TestRenderer.ReactTestRenderer,
): TestRenderer.ReactTestInstance {
return tree.root.findByProps({ "aria-label": "Run title" });
}
describe("EditableRunTitle", () => {
test("renders the run title with an edit affordance", () => {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
});
test("clicking the title swaps to an input pre-filled with the current value", () => {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
expect(findInput(tree).props.value).toBe("Initial title");
});
test("Enter submits a PATCH and the input collapses back to the heading", async () => {
const submitted: unknown[] = [];
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
submitted.push({ url: config.url, method: config.method, body: JSON.parse(String(config.data)) });
return {
data: { id: "run-1", title: "Renamed title" },
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: "Renamed title" } });
});
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
await Promise.resolve();
});
expect(submitted).toEqual([
{ url: "/api/v1/runs/run-1", method: "patch", body: { title: "Renamed title" } },
]);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("Escape exits without sending a request", () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 204,
statusText: "No Content",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: "Discarded" } });
});
act(() => {
input.props.onKeyDown({ key: "Escape", preventDefault: () => {} });
});
expect(calls).toBe(0);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("submitting an empty title does not send a request", async () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: " " } });
});
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
});
expect(calls).toBe(0);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("unchanged title on Enter exits without a request", async () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
});
expect(calls).toBe(0);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
});

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { PencilIcon } from "@heroicons/react/16/solid";
import { ApiError } from "../lib/api-client";
import { useUpdateRunTitle } from "../lib/mutations";
import { InlineMarkdown } from "./inline-markdown";
import { useToast } from "./toast";
const TITLE_MAX_LENGTH = 100;
function focusInputNextFrame(callback: () => void): void {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(callback);
} else {
setTimeout(callback, 0);
}
}
export function EditableRunTitle({ runId, title }: { runId: string; title: string }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(title);
const submittedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateMutation = useUpdateRunTitle(runId);
const { push } = useToast();
const isSaving = updateMutation.isMutating;
useEffect(() => {
if (!isEditing) setDraft(title);
}, [title, isEditing]);
const enterEdit = () => {
setDraft(title);
submittedRef.current = false;
setIsEditing(true);
focusInputNextFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
};
const exitEdit = () => {
setIsEditing(false);
setDraft(title);
};
const submit = async () => {
if (submittedRef.current) return;
const trimmed = draft.trim();
if (trimmed === title.trim()) {
exitEdit();
return;
}
if (trimmed.length === 0) {
push({ message: "Run title can't be blank.", tone: "error" });
inputRef.current?.focus();
return;
}
submittedRef.current = true;
try {
await updateMutation.trigger({ title: trimmed });
setIsEditing(false);
push({ message: "Run title updated." });
} catch (error) {
submittedRef.current = false;
const message = error instanceof ApiError && error.message
? error.message
: "Could not update run title.";
push({ message, tone: "error" });
focusInputNextFrame(() => inputRef.current?.focus());
}
};
if (isEditing) {
const remaining = TITLE_MAX_LENGTH - draft.length;
const showCount = remaining <= 20;
return (
<div className="min-w-0">
<input
ref={inputRef}
name="run-title"
aria-label="Run title"
type="text"
value={draft}
maxLength={TITLE_MAX_LENGTH}
disabled={isSaving}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void submit()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.preventDefault();
submittedRef.current = true;
exitEdit();
}
}}
className="-mx-2 block w-full rounded-md bg-panel-alt px-2 py-0.5 text-xl font-semibold text-fg outline-1 -outline-offset-1 outline-line-strong focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60"
/>
<p className="mt-1.5 flex items-center gap-2 text-xs text-fg-muted">
<span>
{isSaving ? "Saving…" : "Press Enter to save · Esc to cancel"}
</span>
{showCount && !isSaving && (
<span className={remaining < 0 ? "text-coral" : "tabular-nums"}>
{remaining} left
</span>
)}
</p>
</div>
);
}
return (
<h2 className="text-xl font-semibold text-fg">
<button
type="button"
onClick={enterEdit}
aria-label="Edit run title"
className="group/title -mx-2 flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-2 py-0.5 text-left text-fg transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<span className="min-w-0 truncate">
<InlineMarkdown content={title} />
</span>
<PencilIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100"
/>
</button>
</h2>
);
}

View file

@ -0,0 +1,736 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
CheckIcon,
ChevronUpDownIcon,
FunnelIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/16/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { Tooltip } from "./ui";
import { formatAbsoluteTs } from "../lib/format";
export type DebugCategory =
| "agent"
| "command"
| "lifecycle"
| "human"
| "system";
export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"agent",
"command",
"lifecycle",
"human",
"system",
] as const;
const PREFIX_TO_CATEGORY: Record<string, DebugCategory> = {
agent: "agent",
command: "command",
run: "lifecycle",
stage: "lifecycle",
parallel: "lifecycle",
subgraph: "lifecycle",
edge: "lifecycle",
loop: "lifecycle",
prompt: "lifecycle",
interview: "human",
};
const CATEGORY_LABEL: Record<DebugCategory, string> = {
agent: "Agent",
command: "Command",
lifecycle: "Lifecycle",
human: "Human",
system: "System",
};
const CATEGORY_TONE: Record<DebugCategory, string> = {
agent: "bg-teal-500/15 text-teal-500",
command: "bg-mint/15 text-mint",
lifecycle: "bg-amber/15 text-amber",
human: "bg-coral/15 text-coral",
system: "bg-overlay-strong text-fg-3",
};
const CATEGORY_COLOR: Record<DebugCategory, string> = {
agent: "var(--color-teal-500)",
command: "var(--color-mint)",
lifecycle: "var(--color-amber)",
human: "var(--color-coral)",
system: "var(--color-ice-300)",
};
export function debugCategory(eventName: string | null | undefined): DebugCategory {
if (!eventName) return "system";
const dot = eventName.indexOf(".");
const prefix = dot < 0 ? eventName : eventName.slice(0, dot);
return PREFIX_TO_CATEGORY[prefix] ?? "system";
}
export function debugCategoryLabel(category: DebugCategory): string {
return CATEGORY_LABEL[category];
}
export function debugCategoryTone(category: DebugCategory): string {
return CATEGORY_TONE[category];
}
export function debugCategoryColor(category: DebugCategory): string {
return CATEGORY_COLOR[category];
}
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
if (!runStart) return "";
const startMs = Date.parse(runStart);
const eventMs = Date.parse(eventTs);
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
const hours = Math.floor(delta / 3600);
const minutes = Math.floor((delta % 3600) / 60);
const seconds = delta % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
const JSON_TOKEN_RE =
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
export function highlightJson(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
JSON_TOKEN_RE.lastIndex = 0;
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const token = match[0];
let cls: string;
if (token.startsWith('"')) {
const after = text.slice(JSON_TOKEN_RE.lastIndex);
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
} else if (token === "true" || token === "false") {
cls = "text-coral";
} else if (token === "null") {
cls = "text-fg-muted";
} else {
cls = "text-amber";
}
parts.push(
<span key={key++} className={cls}>
{token}
</span>,
);
lastIndex = JSON_TOKEN_RE.lastIndex;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts;
}
export function DebugEventRow({
event,
runStart,
selected,
onSelect,
}: {
event: EventEnvelope;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
const eventName = event.event ?? "";
const category = debugCategory(eventName);
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={`grid w-full grid-cols-[5rem_1fr_auto] items-center gap-4 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
selected ? "bg-overlay" : ""
}`}
>
<span
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
>
{debugCategoryLabel(category)}
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-2">
{eventName}
</span>
<Tooltip label={formatAbsoluteTs(event.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatElapsed(event.ts, runStart)}
</span>
</Tooltip>
</button>
);
}
export function DetailsPanel({
title,
isOpen,
onClose,
children,
}: {
title: string;
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
useEffect(() => {
if (!isOpen) return;
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
return (
<div
className={`relative shrink-0 self-stretch overflow-hidden transition-[width] duration-200 ease-out ${
isOpen ? "w-[28rem]" : "w-0"
}`}
aria-hidden={isOpen ? undefined : true}
>
<div className="absolute inset-y-0 right-0 flex w-[28rem] flex-col border-l border-line bg-panel">
<div className="flex shrink-0 items-center justify-between border-b border-line px-5 py-3">
<h2 className="text-sm font-medium text-fg">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close details"
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<XMarkIcon className="size-5" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4 pb-[calc(1rem+var(--fabro-interview-dock-clearance,0px))]">
{isOpen ? children : null}
</div>
</div>
</div>
);
}
export type EventDisplayPayload = {
event?: string | null;
[key: string]: unknown;
};
export function DebugEventDetailsPanel({
event,
onClose,
}: {
event: EventDisplayPayload | null;
onClose: () => void;
}) {
return (
<DetailsPanel
title={event?.event ?? ""}
isOpen={event != null}
onClose={onClose}
>
{event ? <DebugEventDetails event={event} /> : null}
</DetailsPanel>
);
}
function DebugEventDetails({ event }: { event: EventDisplayPayload }) {
const text = useMemo(() => JSON.stringify(event, null, 2), [event]);
const tokens = useMemo(() => highlightJson(text), [text]);
return (
<pre className="whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
{tokens}
</pre>
);
}
export function MultiSelectFilter<T extends string>({
selected,
options,
labelOf,
onChange,
emptyMeansAll = false,
}: {
selected: T[];
options: readonly T[];
labelOf: (item: T) => string;
onChange: (next: T[]) => void;
emptyMeansAll?: boolean;
}) {
const allSelected = selected.length === options.length;
const summary = useMemo(() => {
if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types";
if (selected.length === 0) return "No types";
if (selected.length <= 2) {
return options
.filter((o) => selected.includes(o))
.map(labelOf)
.join(", ");
}
return `${selected.length} types`;
}, [allSelected, emptyMeansAll, selected, options, labelOf]);
return (
<Listbox value={selected} onChange={onChange} multiple>
<ListboxButton className="inline-flex items-center gap-2 rounded-md bg-panel px-2.5 py-1.5 text-xs text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
<FunnelIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
<span className="tabular-nums">{summary}</span>
<ChevronUpDownIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
</ListboxButton>
<ListboxOptions
transition
anchor={{ to: "bottom start", gap: 4 }}
className="z-20 w-44 rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
{options.map((option) => (
<ListboxOption
key={option}
value={option}
className="group flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden"
>
<span className="flex size-3.5 items-center justify-center rounded-sm border border-line-strong bg-panel-alt group-data-selected:border-teal-500 group-data-selected:bg-teal-500">
<CheckIcon
className="size-2.5 text-on-primary opacity-0 group-data-selected:opacity-100"
aria-hidden="true"
/>
</span>
<span>{labelOf(option)}</span>
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
);
}
export function EventSearchInput({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="relative w-full max-w-sm min-w-48 flex-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-muted"
aria-hidden="true"
/>
<input
type="search"
name="event-search"
aria-label="Search events"
placeholder="Search events"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value)}
className="block w-full rounded-md bg-panel py-1.5 pl-8 pr-2.5 text-xs text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 max-sm:text-base/5"
/>
</div>
);
}
const STRIP_HEIGHT = 32;
const BAR_NORMAL_HEIGHT = 22;
const BAR_HOVER_HEIGHT = 26;
const BAR_SELECTED_HEIGHT = 28;
const BAR_WIDTH = 4;
const STRIP_MAX_MARKERS = 600;
function sampleStripItems<T>(
items: T[],
maxItems: number,
keep: (item: T) => boolean,
): T[] {
if (items.length <= maxItems) return items;
const indices = new Set<number>();
for (let i = 0; i < items.length; i += 1) {
if (keep(items[i])) indices.add(i);
}
for (let i = 0; i < maxItems; i += 1) {
indices.add(Math.round((i * (items.length - 1)) / Math.max(1, maxItems - 1)));
}
return Array.from(indices)
.sort((a, b) => a - b)
.map((index) => items[index]);
}
function friendlyEventName(eventName: string): string {
const parts = eventName.split(".");
if (parts.length <= 1) return eventName;
return parts.slice(1).join(".");
}
export function DebugDnaStrip({
events,
selectedSeq,
onSelect,
runStart,
}: {
events: EventEnvelope[];
selectedSeq: number | null;
onSelect: (seq: number) => void;
runStart: string | undefined;
}) {
const [hover, setHover] = useState<{
seq: number;
rect: DOMRect;
} | null>(null);
const visibleEvents = useMemo(
() => sampleStripItems(events, STRIP_MAX_MARKERS, (event) => event.seq === selectedSeq),
[events, selectedSeq],
);
const visibleEventBySeq = useMemo(
() => new Map(visibleEvents.map((event) => [event.seq, event])),
[visibleEvents],
);
const range = useMemo(() => {
if (events.length === 0) return null;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const event of events) {
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) continue;
if (ms < min) min = ms;
if (ms > max) max = ms;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
const startCandidate = runStart ? Date.parse(runStart) : Number.NaN;
const start = Number.isFinite(startCandidate)
? Math.min(startCandidate, min)
: min;
const duration = Math.max(1, max - start);
return { start, duration };
}, [events, runStart]);
if (!range) {
return (
<div
className="rounded-md bg-overlay"
style={{ height: STRIP_HEIGHT }}
aria-hidden="true"
/>
);
}
const hoveredEvent =
hover != null ? visibleEventBySeq.get(hover.seq) ?? null : null;
return (
<div
className="relative rounded-md bg-overlay px-1.5"
style={{ height: STRIP_HEIGHT }}
>
<div className="relative h-full">
{visibleEvents.map((event) => {
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) return null;
const pct = ((ms - range.start) / range.duration) * 100;
const category = debugCategory(event.event);
const color = debugCategoryColor(category);
const isSelected = event.seq === selectedSeq;
const isHovered = hover?.seq === event.seq;
let height = BAR_NORMAL_HEIGHT;
let opacity = 0.78;
let boxShadow = "none";
if (isSelected) {
height = BAR_SELECTED_HEIGHT;
opacity = 1;
boxShadow = "0 0 0 1px rgba(255,255,255,0.55)";
} else if (isHovered) {
height = BAR_HOVER_HEIGHT;
opacity = 1;
}
const top = (STRIP_HEIGHT - height) / 2;
return (
<div
key={event.seq}
role="button"
tabIndex={-1}
aria-label={`${debugCategoryLabel(category)} · ${event.event}`}
aria-pressed={isSelected}
onMouseEnter={(e) =>
setHover({
seq: event.seq,
rect: e.currentTarget.getBoundingClientRect(),
})
}
onMouseLeave={() =>
setHover((cur) => (cur?.seq === event.seq ? null : cur))
}
onClick={() => onSelect(event.seq)}
className="absolute -translate-x-1/2 cursor-pointer rounded-[1.5px] transition-all duration-100 ease-out"
style={{
left: `${pct}%`,
width: BAR_WIDTH,
height,
top,
opacity,
background: color,
boxShadow,
}}
/>
);
})}
</div>
{hoveredEvent != null && hover != null && (
<DnaPopover event={hoveredEvent} anchorRect={hover.rect} runStart={runStart} />
)}
</div>
);
}
function DnaPopover({
event,
anchorRect,
runStart,
}: {
event: EventEnvelope;
anchorRect: DOMRect;
runStart: string | undefined;
}) {
if (typeof document === "undefined") return null;
const category = debugCategory(event.event);
const left = anchorRect.left + anchorRect.width / 2;
const top = anchorRect.top;
return createPortal(
<div
role="tooltip"
style={{ left, top }}
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-[calc(100%+8px)] whitespace-nowrap rounded-md bg-panel-alt px-2.5 py-1 text-xs text-fg shadow-lg outline-1 -outline-offset-1 outline-line-strong"
>
{`${debugCategoryLabel(category)} · ${friendlyEventName(event.event)} · ${formatElapsed(event.ts, runStart)}`}
</div>,
document.body,
);
}
export type ThreadCategory = "system" | "agent" | "tool" | "user" | "interrupt";
const THREAD_CATEGORY_LABEL: Record<ThreadCategory, string> = {
system: "System",
agent: "Agent",
tool: "Tool",
user: "User",
interrupt: "Interrupt",
};
const THREAD_CATEGORY_COLOR: Record<ThreadCategory, string> = {
system: "var(--color-amber)",
agent: "var(--color-teal-500)",
tool: "var(--color-mint)",
user: "var(--color-ice-300)",
interrupt: "var(--color-coral)",
};
export type ThreadDnaSelection =
| { kind: "single"; turnIndex: number }
| { kind: "group"; childTurnIndices: number[] };
export interface ThreadDnaItem {
category: ThreadCategory;
label: string;
startMs: number;
durationMs: number;
selection: ThreadDnaSelection;
}
const INSTANT_MARKER_PX = 4;
const MIN_DURATION_PX = 3;
function selectionKey(s: ThreadDnaSelection): string {
return s.kind === "single"
? `s:${s.turnIndex}`
: `g:${s.childTurnIndices.join(",")}`;
}
function selectionsEqual(
a: ThreadDnaSelection,
b: ThreadDnaSelection | null,
): boolean {
if (b == null) return false;
if (a.kind === "single" && b.kind === "single") {
return a.turnIndex === b.turnIndex;
}
if (a.kind === "group" && b.kind === "group") {
if (a.childTurnIndices.length !== b.childTurnIndices.length) return false;
return a.childTurnIndices.every((v, i) => v === b.childTurnIndices[i]);
}
return false;
}
function formatThreadElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
function formatThreadDuration(ms: number): string {
if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function ThreadDnaStrip({
items,
selection,
onSelect,
}: {
items: ThreadDnaItem[];
selection: ThreadDnaSelection | null;
onSelect: (s: ThreadDnaSelection) => void;
}) {
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(
null,
);
const visibleItems = useMemo(
() => sampleStripItems(items, STRIP_MAX_MARKERS, (item) =>
selectionsEqual(item.selection, selection)
),
[items, selection],
);
const visibleItemByKey = useMemo(
() =>
new Map(
visibleItems.map((item) => [selectionKey(item.selection), item]),
),
[visibleItems],
);
const totalMs = useMemo(() => {
let max = 0;
for (const item of items) {
const end = item.startMs + Math.max(0, item.durationMs);
if (end > max) max = end;
}
return Math.max(1, max);
}, [items]);
if (items.length === 0) {
return (
<div
className="rounded-md bg-overlay"
style={{ height: STRIP_HEIGHT }}
aria-hidden="true"
/>
);
}
const hoveredItem =
hover != null
? visibleItemByKey.get(hover.key) ?? null
: null;
return (
<div
className="relative rounded-md bg-overlay px-1.5 py-[5px]"
style={{ height: STRIP_HEIGHT }}
>
<div className="relative h-full">
{visibleItems.map((item) => {
const key = selectionKey(item.selection);
const isInstant = item.durationMs <= 0;
const isSelected = selectionsEqual(item.selection, selection);
const isHovered = hover?.key === key;
const leftPct = (item.startMs / totalMs) * 100;
const baseColor = THREAD_CATEGORY_COLOR[item.category];
const style: React.CSSProperties = isInstant
? {
left: `calc(${leftPct}% - ${INSTANT_MARKER_PX / 2}px)`,
width: INSTANT_MARKER_PX,
top: 0,
bottom: 0,
background: baseColor,
opacity: 1,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.55)"
: "none",
}
: {
left: `${leftPct}%`,
width: `max(${MIN_DURATION_PX}px, ${(item.durationMs / totalMs) * 100}%)`,
top: 0,
bottom: 0,
background: baseColor,
opacity: isSelected || isHovered ? 1 : 0.9,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.55)"
: "none",
};
return (
<div
key={key}
role="button"
tabIndex={-1}
aria-label={`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label}`}
aria-pressed={isSelected}
onMouseEnter={(e) =>
setHover({
key,
rect: e.currentTarget.getBoundingClientRect(),
})
}
onMouseLeave={() =>
setHover((cur) => (cur?.key === key ? null : cur))
}
onClick={() => onSelect(item.selection)}
className="absolute cursor-pointer rounded-[2px] transition-all duration-100 ease-out"
style={style}
/>
);
})}
</div>
{hoveredItem != null && hover != null && (
<ThreadDnaPopover item={hoveredItem} anchorRect={hover.rect} />
)}
</div>
);
}
function ThreadDnaPopover({
item,
anchorRect,
}: {
item: ThreadDnaItem;
anchorRect: DOMRect;
}) {
if (typeof document === "undefined") return null;
const left = anchorRect.left + anchorRect.width / 2;
const top = anchorRect.top;
const elapsed = formatThreadElapsed(item.startMs);
const duration =
item.durationMs > 0 ? formatThreadDuration(item.durationMs) : "instant";
return createPortal(
<div
role="tooltip"
style={{ left, top }}
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-[calc(100%+8px)] whitespace-nowrap rounded-md bg-panel-alt px-2.5 py-1 text-xs text-fg shadow-lg outline-1 -outline-offset-1 outline-line-strong"
>
{`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label} · ${elapsed} · ${duration}`}
</div>,
document.body,
);
}

View file

@ -0,0 +1,7 @@
export function GitPullRequestIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
}

View file

@ -0,0 +1,110 @@
import { afterEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { InlineMarkdown } from "./inline-markdown";
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
async function render(content: string, className?: string) {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | undefined;
await act(async () => {
renderer = TestRenderer.create(
<InlineMarkdown content={content} className={className} />,
);
});
mountedRenderers.push(renderer!);
return renderer!;
}
function textFromInstance(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textFromInstance(child)))
.join("");
}
describe("InlineMarkdown", () => {
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("renders backtick spans as <code> without visible backticks", async () => {
const renderer = await render(
"Move from `[server.integrations.github]` to `[run.integrations.github]`",
);
const codes = renderer.root.findAllByType("code");
expect(codes).toHaveLength(2);
expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]");
expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]");
const fullText = textFromInstance(renderer.root);
expect(fullText).not.toContain("`");
});
test("renders **bold** as <strong> and _italic_ as <em>", async () => {
const renderer = await render("**bold** and _italic_");
const strongs = renderer.root.findAllByType("strong");
expect(strongs).toHaveLength(1);
expect(textFromInstance(strongs[0]!)).toBe("bold");
const ems = renderer.root.findAllByType("em");
expect(ems).toHaveLength(1);
expect(textFromInstance(ems[0]!)).toBe("italic");
});
test("renders *italic* (asterisk form) as <em>", async () => {
const renderer = await render("*italic*");
const ems = renderer.root.findAllByType("em");
expect(ems).toHaveLength(1);
expect(textFromInstance(ems[0]!)).toBe("italic");
});
test("block markdown like headings, lists, blockquotes stays text", async () => {
const renderer = await render("# heading - item > quote");
expect(renderer.root.findAllByType("h1")).toHaveLength(0);
expect(renderer.root.findAllByType("h2")).toHaveLength(0);
expect(renderer.root.findAllByType("ul")).toHaveLength(0);
expect(renderer.root.findAllByType("li")).toHaveLength(0);
expect(renderer.root.findAllByType("blockquote")).toHaveLength(0);
expect(textFromInstance(renderer.root)).toContain("# heading");
expect(textFromInstance(renderer.root)).toContain("> quote");
});
test("link syntax renders the label as text without an <a>", async () => {
const renderer = await render("[label](javascript:alert(1))");
expect(renderer.root.findAllByType("a")).toHaveLength(0);
expect(textFromInstance(renderer.root)).toContain("label");
expect(textFromInstance(renderer.root)).not.toContain("javascript:");
});
test("image syntax renders the alt text without an <img>", async () => {
const renderer = await render("![alt](x)");
expect(renderer.root.findAllByType("img")).toHaveLength(0);
expect(textFromInstance(renderer.root)).toContain("alt");
});
test("raw HTML is shown literally as text, not interpreted", async () => {
const renderer = await render("<script>x</script>");
expect(renderer.root.findAllByType("script")).toHaveLength(0);
expect(textFromInstance(renderer.root)).toContain("<script>");
expect(textFromInstance(renderer.root)).toContain("</script>");
});
test("applies a className to the wrapper span", async () => {
const renderer = await render("hello", "text-fg-2");
const wrapper = renderer.root.findByType("span");
expect(wrapper.props.className).toBe("text-fg-2");
});
});

View file

@ -0,0 +1,51 @@
import { Fragment, useMemo, type ReactNode } from "react";
import { Lexer, type Token } from "marked";
const CODE_CLASSNAME =
"rounded bg-overlay-strong px-1 py-0.5 font-mono text-[0.85em] text-fg-2";
function renderTokens(tokens: Token[]): ReactNode[] {
return tokens.map((token, index) => (
<Fragment key={index}>{renderToken(token)}</Fragment>
));
}
function renderToken(token: Token): ReactNode {
switch (token.type) {
case "codespan":
return <code className={CODE_CLASSNAME}>{token.text}</code>;
case "strong":
return <strong>{renderTokens(token.tokens)}</strong>;
case "em":
return <em>{renderTokens(token.tokens)}</em>;
case "del":
return renderTokens(token.tokens);
case "link":
return token.tokens.length > 0 ? renderTokens(token.tokens) : token.text;
case "image":
return token.text;
case "html":
return token.raw;
case "br":
return " ";
case "escape":
return token.text;
case "text":
return token.tokens && token.tokens.length > 0
? renderTokens(token.tokens)
: token.text;
default:
return "raw" in token ? token.raw : "";
}
}
export function InlineMarkdown({
content,
className,
}: {
content: string;
className?: string;
}) {
const children = useMemo(() => renderTokens(Lexer.lexInline(content)), [content]);
return <span className={className}>{children}</span>;
}

View file

@ -7,6 +7,7 @@ import {
} from "@qltysh/fabro-api-client";
import { InterviewDock, displayLabel } from "./interview-dock";
import { generatedAxios } from "../lib/api-client";
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
@ -81,6 +82,41 @@ describe("InterviewDock", () => {
expect(buttons.No).toBeDefined();
});
test("yes/no question submits typed yes and no answers", async () => {
const submitted: unknown[] = [];
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
submitted.push(JSON.parse(String(config.data)));
return {
data: undefined,
status: 204,
statusText: "No Content",
headers: {},
config,
};
};
try {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
const buttons = buttonsByText(tree);
await act(async () => {
buttons.Yes.props.onClick();
await Promise.resolve();
});
await act(async () => {
buttons.No.props.onClick();
await Promise.resolve();
});
expect(submitted).toEqual([{ kind: "yes" }, { kind: "no" }]);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("multiple choice question renders option buttons with stripped accelerator prefixes", () => {
const question = makeQuestion({
question_type: QuestionType.MULTIPLE_CHOICE,

View file

@ -22,6 +22,7 @@ import {
useSubmitInterviewAnswer,
type SubmitInterviewAnswerArg,
} from "../lib/mutations";
import { ApiError } from "../lib/api-client";
import { ErrorMessage } from "./ui";
const PRIMARY_BUTTON =
@ -33,6 +34,8 @@ const CHOICE_BUTTON =
const CHOICE_BUTTON_SELECTED =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"];
export interface InterviewDockProps {
runId: string;
questions: ApiQuestion[];
@ -52,15 +55,13 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) {
}, [question?.id, submitMutation.reset]);
const submit = useCallback(
async (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => {
async (answer: SubmitInterviewAnswer) => {
if (!question) return;
setError(null);
try {
await submitMutation.trigger({ ...arg, questionId: question.id });
await submitMutation.trigger({ questionId: question.id, answer });
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Couldn't submit your answer.",
);
setError(interviewSubmitErrorMessage(caught));
}
},
[question, submitMutation],
@ -72,47 +73,37 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) {
const submitting = submitMutation.isMutating;
return (
<div
role="region"
aria-label="Interview question"
className="pointer-events-none fixed inset-x-0 bottom-0 z-30"
>
<div className="bg-linear-to-t from-page via-page/80 to-transparent pt-10">
<div className="pointer-events-auto mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-t-2xl bg-panel shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.5)] ring-1 ring-line-strong">
<DockHeader
stage={question.stage}
moreCount={moreCount}
onCycle={() =>
setActiveIndex((index) => (index + 1) % questions.length)
}
/>
<div className="space-y-5 p-5 sm:p-6">
<div>
<p className="text-pretty text-base/6 font-medium text-fg">
{question.text}
</p>
<p className="mt-1 text-xs/5 text-fg-muted">
{questionTypeLabel(question.question_type)}
</p>
</div>
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && <ErrorMessage message={error} />}
</div>
</div>
<section role="region" aria-label="Interview question">
<DockHeader
stage={question.stage}
moreCount={moreCount}
onCycle={() =>
setActiveIndex((index) => (index + 1) % questions.length)
}
/>
<div className="space-y-5 px-5 py-4 sm:px-6">
<div>
<p className="text-pretty text-base/6 font-medium text-fg">
{question.text}
</p>
<p className="mt-1 text-xs/5 text-fg-muted">
{questionTypeLabel(question.question_type)}
</p>
</div>
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && <ErrorMessage message={error} />}
</div>
</div>
</section>
);
}
@ -182,7 +173,7 @@ function QuestionBody({
}: {
question: ApiQuestion;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
switch (question.question_type) {
case QuestionType.YES_NO:
@ -226,14 +217,14 @@ function YesNoBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "no" })}
onClick={() => void onSubmit({ kind: "no" })}
className={CHOICE_BUTTON}
>
No
@ -241,7 +232,7 @@ function YesNoBody({
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -256,14 +247,14 @@ function ConfirmationBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -282,7 +273,7 @@ function ChoiceBody({
options: ApiQuestionOption[];
allowFreeform: boolean;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="space-y-4">
@ -293,7 +284,7 @@ function ChoiceBody({
key={option.key}
type="button"
disabled={submitting}
onClick={() => void onSubmit({ selected_option_key: option.key })}
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
className={CHOICE_BUTTON}
>
{displayLabel(option.label)}
@ -325,7 +316,7 @@ function MultiSelectBody({
}: {
options: ApiQuestionOption[];
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
@ -367,7 +358,7 @@ function MultiSelectBody({
<button
type="button"
disabled={submitting || selectedKeys.length === 0}
onClick={() => void onSubmit({ selected_option_keys: selectedKeys })}
onClick={() => void onSubmit({ kind: "multi_selected", option_keys: selectedKeys })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -387,7 +378,7 @@ function FreeformBody({
divider = false,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
placeholder: string;
submitLabel: string;
autoFocus?: boolean;
@ -404,7 +395,7 @@ function FreeformBody({
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting) return;
await onSubmit({ value: trimmed });
await onSubmit({ kind: "text", text: trimmed });
setValue("");
}
@ -494,3 +485,12 @@ export function displayLabel(label: string): string {
.trim();
return stripped || trimmed;
}
function interviewSubmitErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.requestId
? `${error.message} Request ID: ${error.requestId}`
: error.message;
}
return error instanceof Error ? error.message : "Couldn't submit your answer.";
}

View file

@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { GitPullRequestIcon } from "./icons";
export function PullRequestChip({
number,
url,
className = "inline-flex items-center gap-1.5 font-mono text-xs text-fg-muted",
iconClassName = "size-3",
children,
}: {
number: number;
url?: string;
className?: string;
iconClassName?: string;
children?: ReactNode;
}) {
const content = (
<>
<GitPullRequestIcon className={iconClassName} />
#{number}
{children}
</>
);
if (url == null) {
return <span className={className}>{content}</span>;
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className={`${className} hover:text-fg`}
>
{content}
</a>
);
}

View file

@ -0,0 +1,128 @@
import { useMemo } from "react";
import { Link } from "react-router";
import {
ArrowLongRightIcon,
ArrowsRightLeftIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { findEdgeForNode } from "./helpers";
const REASON_LABEL: Record<string, string> = {
condition: "Matched condition",
unconditional: "Default edge",
jump: "Jumped",
preferred_label: "Preferred label",
};
function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? reason;
}
export function ConditionalDecision({
stage,
runEvents,
allStages,
runId,
}: {
stage: Stage;
runEvents: EventEnvelope[];
allStages: Stage[];
runId: string;
}) {
const edge = useMemo(
() => findEdgeForNode(runEvents, stage.nodeId),
[runEvents, stage.nodeId],
);
const targetStage = useMemo(() => {
if (!edge) return null;
let pick: Stage | null = null;
for (const s of allStages) {
if (s.nodeId !== edge.toNode) continue;
if (!pick || s.visit > pick.visit) pick = s;
}
return pick;
}, [allStages, edge]);
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="rounded-lg bg-panel p-6 outline-1 -outline-offset-1 outline-line">
<div className="flex items-center gap-2 text-xs text-fg-muted">
<ArrowsRightLeftIcon className="size-4" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider">Decision</span>
</div>
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div className="rounded-md bg-overlay-strong px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
From
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg-2">
{stage.nodeId}
</div>
</div>
<ArrowLongRightIcon
className="size-6 text-fg-muted"
aria-hidden="true"
/>
{edge ? (
targetStage ? (
<Link
to={`/runs/${runId}/stages/${targetStage.id}`}
className="group rounded-md bg-teal-500/10 px-3 py-2 text-center transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
<div className="text-[10px] font-medium uppercase tracking-wider text-teal-500">
To
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg group-hover:text-fg">
{edge.toNode}
</div>
</Link>
) : (
<div className="rounded-md bg-teal-500/10 px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-teal-500">
To
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg">
{edge.toNode}
</div>
</div>
)
) : (
<div className="rounded-md bg-overlay-strong px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
To
</div>
<div className="mt-0.5 text-sm text-fg-muted">—</div>
</div>
)}
</div>
{edge && (
<dl className="mt-5 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-xs">
<dt className="text-fg-muted">Reason</dt>
<dd className="text-fg-2">{reasonLabel(edge.reason)}</dd>
{edge.condition && (
<>
<dt className="text-fg-muted">Condition</dt>
<dd className="rounded bg-overlay-strong px-2 py-1 font-mono text-fg-3">
{edge.condition}
</dd>
</>
)}
{edge.isJump && (
<>
<dt className="text-fg-muted">Edge type</dt>
<dd className="text-fg-2">Jump</dd>
</>
)}
</dl>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,190 @@
import { useMemo } from "react";
import {
CpuChipIcon,
SparklesIcon,
TrophyIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { formatTokenCount } from "../../lib/format";
import { getString } from "../../lib/unknown";
import { Markdown, prettyJson } from "./primitives";
import { StageMetaBar } from "./meta-bar";
import { parseFanInOutcome } from "./helpers";
interface ReducerTurn {
prompt: string;
response: string;
model: string | null;
inputTokens: number;
outputTokens: number;
}
function extractReducerTurn(events: EventEnvelope[]): ReducerTurn | null {
let prompt = "";
let response = "";
let model: string | null = null;
let inputTokens = 0;
let outputTokens = 0;
let hasReducer = false;
for (const event of events) {
const props = event.properties ?? {};
if (event.event === "stage.prompt" && getString(props, "mode") === "fan_in") {
prompt = getString(props, "text") ?? prompt;
hasReducer = true;
} else if (event.event === "prompt.completed") {
response = getString(props, "response") ?? response;
model = getString(props, "model") ?? model;
const billing = props.billing as Record<string, unknown> | undefined;
if (billing) {
const it = billing.input_tokens;
const ot = billing.output_tokens;
if (typeof it === "number") inputTokens = it;
if (typeof ot === "number") outputTokens = ot;
}
hasReducer = true;
}
}
return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null;
}
/**
* The fan-in `stage.prompt.text` is built by the handler as
* "<prompt>\n\n<json>". Split it for nicer display so the JSON candidate set
* lands in a code block rather than fighting markdown rendering.
*/
function splitPromptAndCandidates(text: string): { prompt: string; candidatesJson: string } {
const trimmed = text.trim();
// Find the start of the JSON envelope. The handler always uses array results
// so look for the first opening bracket that begins a balanced array/object.
const idx = (() => {
for (let i = 0; i < trimmed.length; i += 1) {
const ch = trimmed[i];
if (ch === "[" || ch === "{") return i;
}
return -1;
})();
if (idx < 0) return { prompt: trimmed, candidatesJson: "" };
const promptPart = trimmed.slice(0, idx).trim();
const jsonPart = trimmed.slice(idx).trim();
const pretty = prettyJson(jsonPart);
return {
prompt: promptPart,
candidatesJson: pretty.isJson ? pretty.text : jsonPart,
};
}
export function FanInResults({
stage,
events,
notes,
}: {
stage: Stage;
events: EventEnvelope[];
notes: string | null;
}) {
const outcome = useMemo(() => parseFanInOutcome(events, notes), [events, notes]);
const reducer = useMemo(() => extractReducerTurn(events), [events]);
const promptParts = useMemo(
() => (reducer ? splitPromptAndCandidates(reducer.prompt) : null),
[reducer],
);
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar
stage={stage}
trailing={
outcome.reducerModel ? (
<span className="inline-flex items-center gap-1.5 text-xs text-fg-muted">
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{outcome.reducerModel}</span>
</span>
) : null
}
/>
<section className="overflow-hidden rounded-lg bg-gradient-to-br from-amber/10 via-panel to-panel outline-1 -outline-offset-1 outline-line">
<div className="flex flex-col gap-5 p-6 sm:flex-row sm:items-center sm:gap-8">
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-amber/15 ring-1 ring-amber/30">
<TrophyIcon className="size-7 text-amber" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber">
Selected branch
</div>
{outcome.selectedId ? (
<p className="mt-1 truncate font-mono text-2xl text-fg">
{outcome.selectedId}
</p>
) : (
<p className="mt-1 text-sm text-fg-muted">
Awaiting fan-in completion
</p>
)}
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-fg-muted">
{outcome.hasReducerTranscript ? (
<>
<SparklesIcon className="size-3.5" aria-hidden="true" />
Selected by LLM reducer
</>
) : (
<>Selected by heuristic (status &middot; score &middot; id)</>
)}
</p>
</div>
</div>
</section>
{reducer && promptParts && (
<section className="space-y-4">
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
Reducer transcript
</h3>
<article className="rounded-lg bg-panel p-4 outline-1 -outline-offset-1 outline-line">
<header className="mb-2 flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider">
<span className="rounded-full bg-amber/15 px-2 py-0.5 text-amber">
Prompt
</span>
</header>
{promptParts.prompt && (
<Markdown content={promptParts.prompt} />
)}
{promptParts.candidatesJson && (
<details className="mt-3 group">
<summary className="cursor-pointer text-xs text-fg-muted hover:text-fg-2">
Candidates JSON
</summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
{promptParts.candidatesJson}
</pre>
</details>
)}
</article>
<article className="rounded-lg bg-panel p-4 outline-1 -outline-offset-1 outline-line">
<header className="mb-2 flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider">
<span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-teal-500">
Response
</span>
{(reducer.inputTokens > 0 || reducer.outputTokens > 0) && (
<span className="ml-auto font-mono normal-case tracking-normal text-fg-muted">
{formatTokenCount(reducer.inputTokens)} / {formatTokenCount(reducer.outputTokens)} tokens
</span>
)}
</header>
{reducer.response ? (
<Markdown content={reducer.response} />
) : (
<p className="text-sm text-fg-muted">No response recorded.</p>
)}
</article>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,208 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
extractStageNotes,
parseFanInOutcome,
parseHumanInterviewPairs,
parseParallelOverview,
} from "./helpers";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
seq,
id: `evt-${seq}`,
ts: `2026-04-09T12:00:0${seq}Z`,
run_id: "run-1",
event: "stage.prompt",
...partial,
} as EventEnvelope;
}
describe("parseHumanInterviewPairs", () => {
test("pairs interview.started with interview.completed by question_id", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question: "Approve PR?",
question_type: "yes_no",
options: [
{ key: "y", label: "Yes" },
{ key: "n", label: "No" },
],
allow_freeform: false,
},
}),
envelope(2, {
event: "interview.completed",
properties: {
question_id: "q-1",
question: "Approve PR?",
answer: "y",
duration_ms: 4200,
actor: { kind: "user", email: "alice@example.com" },
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs).toHaveLength(1);
expect(pairs[0].question.questionType).toBe("yes_no");
expect(pairs[0].question.options).toEqual([
{ key: "y", label: "Yes" },
{ key: "n", label: "No" },
]);
const resolution = pairs[0].resolution;
expect(resolution).not.toBeNull();
if (resolution?.kind === "answered") {
expect(resolution.answer).toBe("y");
expect(resolution.actor).toBe("alice@example.com");
expect(resolution.durationMs).toBe(4200);
}
});
test("leaves resolution null for unanswered (still pending) questions", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question: "Pick a branch",
question_type: "multiple_choice",
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].resolution).toBeNull();
});
test("captures timeout and interrupted resolutions", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "interview.started",
properties: { question_id: "q-1", question: "?", question_type: "freeform" },
}),
envelope(2, {
event: "interview.timeout",
properties: { question_id: "q-1", duration_ms: 30000 },
}),
envelope(3, {
event: "interview.started",
properties: { question_id: "q-2", question: "?", question_type: "freeform" },
}),
envelope(4, {
event: "interview.interrupted",
properties: {
question_id: "q-2",
reason: "user cancelled",
duration_ms: 1200,
actor: { kind: "user", email: "bob@example.com" },
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].resolution?.kind).toBe("timeout");
expect(pairs[1].resolution?.kind).toBe("interrupted");
if (pairs[1].resolution?.kind === "interrupted") {
expect(pairs[1].resolution.reason).toBe("user cancelled");
expect(pairs[1].resolution.actor).toBe("bob@example.com");
}
});
});
describe("parseParallelOverview", () => {
test("rolls up branch_count from started and results from completed", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "parallel.started",
properties: { branch_count: 3, join_policy: "wait_all" },
}),
envelope(2, {
event: "parallel.completed",
properties: {
success_count: 2,
failure_count: 1,
results: [
{ id: "branch-a", status: "succeeded", head_sha: "abc1234567890" },
{ id: "branch-b", status: "succeeded" },
{ id: "branch-c", status: "failed" },
],
},
}),
];
const overview = parseParallelOverview(events);
expect(overview).toMatchObject({
branchCount: 3,
joinPolicy: "wait_all",
successCount: 2,
failureCount: 1,
isComplete: true,
});
expect(overview.results).toEqual([
{ id: "branch-a", status: "succeeded", headSha: "abc1234567890" },
{ id: "branch-b", status: "succeeded", headSha: null },
{ id: "branch-c", status: "failed", headSha: null },
]);
});
test("reports in-flight when only the started event is present", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "parallel.started",
properties: { branch_count: 4, join_policy: "first_success" },
}),
];
const overview = parseParallelOverview(events);
expect(overview.isComplete).toBe(false);
expect(overview.branchCount).toBe(4);
expect(overview.results).toEqual([]);
});
});
describe("parseFanInOutcome", () => {
test("extracts the selected branch id from notes", () => {
const outcome = parseFanInOutcome([], "Selected best candidate: branch-42");
expect(outcome.selectedId).toBe("branch-42");
expect(outcome.hasReducerTranscript).toBe(false);
});
test("flags reducer presence when fan-in prompt events exist", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
properties: { mode: "fan_in", text: "rank these" },
}),
envelope(2, {
event: "prompt.completed",
properties: { response: "branch-a wins", model: "claude-sonnet-4-6" },
}),
];
const outcome = parseFanInOutcome(events, "Selected best candidate: branch-a");
expect(outcome.hasReducerTranscript).toBe(true);
expect(outcome.reducerModel).toBe("claude-sonnet-4-6");
expect(outcome.selectedId).toBe("branch-a");
});
test("returns null selection when notes lack the selected line", () => {
const outcome = parseFanInOutcome([], "all candidates failed");
expect(outcome.selectedId).toBeNull();
});
});
describe("extractStageNotes", () => {
test("returns notes from the stage.completed event", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.completed",
properties: { notes: "Stop condition satisfied at cycle 7" },
}),
];
expect(extractStageNotes(events)).toBe("Stop condition satisfied at cycle 7");
});
test("returns null when there is no stage.completed event", () => {
expect(extractStageNotes([])).toBeNull();
});
});

View file

@ -0,0 +1,290 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { getArray, getNumber, getObject, getString, type UnknownRecord } from "../../lib/unknown";
export interface InterviewOption {
key: string;
label: string;
}
export interface HumanQuestion {
ts: string;
questionId: string;
question: string;
questionType: string;
options: InterviewOption[];
allowFreeform: boolean;
timeoutSeconds: number | null;
contextDisplay: string | null;
}
export type HumanResolution =
| { kind: "answered"; ts: string; answer: string; durationMs: number; actor: string | null }
| { kind: "timeout"; ts: string; durationMs: number }
| { kind: "interrupted"; ts: string; reason: string; durationMs: number; actor: string | null };
export interface HumanInterviewPair {
question: HumanQuestion;
resolution: HumanResolution | null;
}
function principalLabel(actor: unknown): string | null {
if (!actor || typeof actor !== "object") return null;
const record = actor as UnknownRecord;
const kind = getString(record, "kind") ?? "";
if (kind === "user") {
const email = getString(record, "email");
const id = getString(record, "id");
return email ?? id ?? "user";
}
if (kind === "worker") return "worker";
if (kind === "webhook") return "webhook";
if (kind === "slack") {
const userId = getString(record, "user_id");
return userId ? `slack:${userId}` : "slack";
}
return kind || null;
}
function parseInterviewOptions(value: unknown): InterviewOption[] {
if (!Array.isArray(value)) return [];
const out: InterviewOption[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as UnknownRecord;
const key = getString(record, "key");
const label = getString(record, "label");
if (key && label) out.push({ key, label });
}
return out;
}
/**
* Pair `interview.started` events with the matching `interview.completed`,
* `.timeout`, or `.interrupted` resolution by `question_id`. Unanswered
* questions return with `resolution: null` so the UI can show pending state.
*/
export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanInterviewPair[] {
const pairs = new Map<string, HumanInterviewPair>();
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "interview.started") {
const questionId = getString(props, "question_id");
if (!questionId) continue;
pairs.set(questionId, {
question: {
ts: event.ts,
questionId,
question: getString(props, "question") ?? "",
questionType: getString(props, "question_type") ?? "freeform",
options: parseInterviewOptions(props.options),
allowFreeform: props.allow_freeform === true,
timeoutSeconds: getNumber(props, "timeout_seconds") ?? null,
contextDisplay: getString(props, "context_display") ?? null,
},
resolution: null,
});
continue;
}
if (event.event === "interview.completed") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "answered",
ts: event.ts,
answer: getString(props, "answer") ?? "",
durationMs: getNumber(props, "duration_ms") ?? 0,
actor: principalLabel(props.actor),
};
continue;
}
if (event.event === "interview.timeout") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "timeout",
ts: event.ts,
durationMs: getNumber(props, "duration_ms") ?? 0,
};
continue;
}
if (event.event === "interview.interrupted") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "interrupted",
ts: event.ts,
reason: getString(props, "reason") ?? "interrupted",
durationMs: getNumber(props, "duration_ms") ?? 0,
actor: principalLabel(props.actor),
};
}
}
return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts));
}
export interface ParallelBranchResult {
id: string;
status: string;
headSha: string | null;
}
export interface ParallelOverview {
branchCount: number | null;
joinPolicy: string | null;
successCount: number | null;
failureCount: number | null;
durationMs: number | null;
results: ParallelBranchResult[];
isComplete: boolean;
}
/**
* Roll up the `parallel.started` (announces branch count) and
* `parallel.completed` (carries the rolled-up results) events for a parallel
* stage. Pre-completion, only the announce data is available.
*/
export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview {
let branchCount: number | null = null;
let joinPolicy: string | null = null;
let successCount: number | null = null;
let failureCount: number | null = null;
let durationMs: number | null = null;
let results: ParallelBranchResult[] = [];
let isComplete = false;
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "parallel.started") {
branchCount = getNumber(props, "branch_count") ?? branchCount;
joinPolicy = getString(props, "join_policy") ?? joinPolicy;
} else if (event.event === "parallel.completed") {
isComplete = true;
successCount = getNumber(props, "success_count") ?? successCount;
failureCount = getNumber(props, "failure_count") ?? failureCount;
durationMs = getNumber(props, "duration_ms") ?? durationMs;
const rawResults = getArray(props, "results") ?? [];
results = rawResults
.map((entry) => {
const record = entry && typeof entry === "object" ? (entry as UnknownRecord) : null;
if (!record) return null;
return {
id: getString(record, "id") ?? "",
status: getString(record, "status") ?? "unknown",
headSha: getString(record, "head_sha") ?? null,
} satisfies ParallelBranchResult;
})
.filter((r): r is ParallelBranchResult => r != null && r.id !== "");
if (branchCount == null) branchCount = results.length;
}
}
return {
branchCount,
joinPolicy,
successCount,
failureCount,
durationMs,
results,
isComplete,
};
}
export interface FanInOutcome {
selectedId: string | null;
hasReducerTranscript: boolean;
reducerModel: string | null;
}
const FAN_IN_NOTES_RE = /Selected best candidate:\s*(.+?)\s*$/;
/**
* Derive the fan-in winner from the `parallel.completed`-style notes string,
* and report whether reducer LLM events were emitted (so the UI knows to show
* the embedded transcript).
*/
export function parseFanInOutcome(events: EventEnvelope[], notes: string | null): FanInOutcome {
const match = notes ? FAN_IN_NOTES_RE.exec(notes) : null;
let hasReducerTranscript = false;
let reducerModel: string | null = null;
for (const event of events) {
if (event.event === "stage.prompt") {
const mode = getString(event.properties ?? {}, "mode");
if (mode === "fan_in") hasReducerTranscript = true;
}
if (event.event === "prompt.completed") {
hasReducerTranscript = true;
const model = getString(event.properties ?? {}, "model");
if (model) reducerModel = model;
}
}
return {
selectedId: match ? match[1].trim() : null,
hasReducerTranscript,
reducerModel,
};
}
export function asUnknownRecord(value: unknown): UnknownRecord | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as UnknownRecord;
}
/**
* Extract `notes` from the `stage.completed` event scoped to this stage.
* Returns null when the stage hasn't finished yet or when notes are absent.
*/
export function extractStageNotes(events: EventEnvelope[]): string | null {
for (const event of events) {
if (event.event !== "stage.completed") continue;
const notes = getString(event.properties ?? {}, "notes");
if (notes) return notes;
}
return null;
}
export interface EdgeSelection {
fromNode: string;
toNode: string;
reason: string;
condition: string | null;
isJump: boolean;
}
/**
* Find the `edge.selected` event whose `from_node` matches this conditional
* stage's node. Edge events are run-scoped (no stage_id) so callers must pass
* the full run events list, not the per-stage events.
*
* When the stage runs multiple times, the most recent matching event wins —
* for now we just take the last one. Sufficient until we surface visit data.
*/
export function findEdgeForNode(
runEvents: EventEnvelope[],
nodeId: string,
): EdgeSelection | null {
let latest: EdgeSelection | null = null;
for (const event of runEvents) {
if (event.event !== "edge.selected") continue;
const props = event.properties ?? {};
const fromNode = getString(props, "from_node");
if (fromNode !== nodeId) continue;
const toNode = getString(props, "to_node") ?? "";
latest = {
fromNode: nodeId,
toNode,
reason: getString(props, "reason") ?? "",
condition: getString(props, "condition") ?? null,
isJump: props.is_jump === true,
};
}
return latest;
}
// Re-export helper used by renderers that need to read nested properties.
export { getObject, getString, getNumber, getArray };

View file

@ -0,0 +1,249 @@
import { useMemo } from "react";
import {
ArrowPathIcon,
ChatBubbleLeftEllipsisIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { Tooltip } from "../ui";
import { formatAbsoluteTs, formatDurationMs } from "../../lib/format";
import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar";
import { Markdown } from "./primitives";
import { StageMetaBar } from "./meta-bar";
import {
parseHumanInterviewPairs,
type HumanInterviewPair,
type HumanResolution,
type InterviewOption,
} from "./helpers";
function questionTypeLabel(type: string): string {
switch (type) {
case "multiple_choice":
case "MultipleChoice":
return "Multiple choice";
case "yes_no":
case "YesNo":
return "Yes / No";
case "freeform":
case "Freeform":
return "Freeform";
default:
return type.replace(/_/g, " ");
}
}
function answerLookup(options: InterviewOption[], answer: string): InterviewOption | null {
const trimmed = answer.trim();
return options.find((o) => o.key === trimmed || o.label === trimmed) ?? null;
}
function ResolutionBlock({
resolution,
options,
}: {
resolution: HumanResolution;
options: InterviewOption[];
}) {
if (resolution.kind === "answered") {
const matched = answerLookup(options, resolution.answer);
return (
<div className="rounded-lg bg-teal-500/5 p-4 outline-1 -outline-offset-1 outline-teal-500/20">
<div className="mb-2 flex items-center gap-2 text-xs">
<CheckCircleIcon className="size-3.5 text-teal-500" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider text-teal-500">
Answered
</span>
{resolution.actor && (
<span className="text-fg-muted">
by <span className="font-mono text-fg-3">{resolution.actor}</span>
</span>
)}
<span className="ml-auto inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3" aria-hidden="true" />
{formatDurationMs(resolution.durationMs)}
</span>
</div>
{matched ? (
<div className="flex items-baseline gap-3">
<span className="font-mono text-xs text-fg-muted">{matched.key}</span>
<span className="text-sm text-fg-2">{matched.label}</span>
</div>
) : (
<div className="text-sm text-fg-2">
<Markdown content={resolution.answer || "(empty)"} />
</div>
)}
</div>
);
}
if (resolution.kind === "timeout") {
return (
<div className="rounded-lg bg-amber/5 p-4 outline-1 -outline-offset-1 outline-amber/20">
<div className="flex items-center gap-2 text-xs">
<ExclamationTriangleIcon className="size-3.5 text-amber" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider text-amber">Timed out</span>
<span className="ml-auto inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3" aria-hidden="true" />
{formatDurationMs(resolution.durationMs)}
</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-coral/5 p-4 outline-1 -outline-offset-1 outline-coral/20">
<div className="flex items-center gap-2 text-xs">
<NoSymbolIcon className="size-3.5 text-coral" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider text-coral">Interrupted</span>
{resolution.actor && (
<span className="text-fg-muted">
by <span className="font-mono text-fg-3">{resolution.actor}</span>
</span>
)}
<span className="ml-auto inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3" aria-hidden="true" />
{formatDurationMs(resolution.durationMs)}
</span>
</div>
{resolution.reason && (
<p className="mt-2 text-sm text-fg-3">Reason: {resolution.reason}</p>
)}
</div>
);
}
function PendingBlock({ stageActive }: { stageActive: boolean }) {
return (
<div className="rounded-lg bg-overlay-strong p-4 outline-1 -outline-offset-1 outline-line-strong">
<div className="flex items-center gap-2 text-xs">
{stageActive ? (
<ArrowPathIcon
className="size-3.5 animate-spin text-teal-500"
aria-hidden="true"
/>
) : (
<ClockIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
)}
<span className="font-medium uppercase tracking-wider text-fg-muted">
{stageActive ? "Awaiting answer…" : "Unanswered"}
</span>
</div>
</div>
);
}
function QuestionBlock({
pair,
stageActive,
}: {
pair: HumanInterviewPair;
stageActive: boolean;
}) {
const { question, resolution } = pair;
return (
<article className="space-y-3">
<header className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1.5 rounded-full bg-overlay-strong px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-fg-2">
<ChatBubbleLeftEllipsisIcon className="size-3" aria-hidden="true" />
{questionTypeLabel(question.questionType)}
</span>
{question.timeoutSeconds && (
<span className="text-xs text-fg-muted">
timeout {Math.round(question.timeoutSeconds)}s
</span>
)}
<Tooltip label={formatAbsoluteTs(question.ts)}>
<span className="ml-auto font-mono text-[11px] tabular-nums text-fg-muted">
{new Date(question.ts).toLocaleTimeString()}
</span>
</Tooltip>
</header>
<div className="rounded-lg bg-panel p-4 outline-1 -outline-offset-1 outline-line">
<Markdown content={question.question} />
{question.contextDisplay && (
<div className="mt-3 border-t border-line pt-3 text-xs text-fg-muted">
<Markdown content={question.contextDisplay} />
</div>
)}
{question.options.length > 0 && (
<ul className="mt-3 space-y-1.5">
{question.options.map((option) => (
<li
key={option.key}
className="flex items-baseline gap-3 rounded-md bg-overlay px-3 py-1.5"
>
<span className="inline-flex size-5 shrink-0 items-center justify-center rounded bg-overlay-strong font-mono text-[11px] text-fg-2">
{option.key}
</span>
<span className="text-sm text-fg-3">{option.label}</span>
</li>
))}
{question.allowFreeform && (
<li className="text-xs italic text-fg-muted">Freeform answer also accepted</li>
)}
</ul>
)}
</div>
{resolution ? (
<ResolutionBlock resolution={resolution} options={question.options} />
) : (
<PendingBlock stageActive={stageActive} />
)}
</article>
);
}
export function HumanQA({
stage,
events,
}: {
stage: Stage;
events: EventEnvelope[];
}) {
const pairs = useMemo(() => parseHumanInterviewPairs(events), [events]);
const stageActive = ACTIVE_STAGE_STATES.has(stage.status);
const pendingCount = pairs.filter((p) => p.resolution == null).length;
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar
stage={stage}
trailing={
<span className="inline-flex items-center gap-1 text-xs text-fg-muted">
<span className="font-mono tabular-nums">{pairs.length}</span>
{pairs.length === 1 ? "question" : "questions"}
{pendingCount > 0 && (
<span className="ml-2 inline-flex items-center rounded-full bg-amber/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-amber">
{pendingCount} pending
</span>
)}
</span>
}
/>
{pairs.length === 0 ? (
<p className="text-sm text-fg-muted">
{stageActive ? "Waiting for the first question…" : "No questions were asked."}
</p>
) : (
<ol className="space-y-6">
{pairs.map((pair) => (
<li key={pair.question.questionId}>
<QuestionBlock pair={pair} stageActive={stageActive} />
</li>
))}
</ol>
)}
</div>
);
}

View file

@ -0,0 +1,91 @@
import type { ReactNode } from "react";
import { ClockIcon } from "@heroicons/react/20/solid";
import type { Stage } from "../stage-sidebar";
import { Tooltip } from "../ui";
import { formatAbsoluteTs } from "../../lib/format";
import { ACTIVE_STAGE_STATES, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { useTickingNow } from "../../lib/time";
function liveDuration(startedAt: string | null, fallback: string): string {
if (!startedAt) return fallback;
const startMs = Date.parse(startedAt);
if (Number.isNaN(startMs)) return fallback;
const secs = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
if (secs < 60) return `${secs}s`;
const minutes = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (minutes < 60) return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainMin = minutes % 60;
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
}
/**
* Compact horizontal status strip used at the top of every specialized stage
* renderer. Shows the status pill and live-or-final duration; the timestamp
* is exposed via tooltip on the duration to avoid stuffing the bar with
* extra labels. The trailing slot is right-aligned for renderer-specific
* metadata (chips, counts, model badges, etc.).
*/
export function StageMetaBar({
stage,
trailing,
}: {
stage: Stage;
trailing?: ReactNode;
}) {
const isActive = ACTIVE_STAGE_STATES.has(stage.status);
// Re-render every second while running so the elapsed clock keeps up.
useTickingNow(isActive);
const duration = isActive ? liveDuration(stage.startedAt, stage.duration) : stage.duration;
const durationNode = (
<span className="inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
{duration}
</span>
);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider ${stageStatusTone(stage.status)}`}
>
{stageStatusLabel(stage.status)}
</span>
{stage.startedAt ? (
<Tooltip label={`Started ${formatAbsoluteTs(stage.startedAt)}`}>
{durationNode}
</Tooltip>
) : (
durationNode
)}
{trailing && <span className="ml-auto inline-flex items-center gap-3">{trailing}</span>}
</div>
);
}
/**
* Section heading used inside renderer bodies. Pairs an h3 title with optional
* supporting text underneath.
*/
export function SectionHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<header className="flex items-baseline justify-between gap-3 pb-3">
<div>
<h3 className="text-sm font-medium text-fg">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
</div>
{action}
</header>
);
}

View file

@ -0,0 +1,196 @@
import { useMemo } from "react";
import { Link } from "react-router";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { CopyButton } from "../ui";
import { formatDurationMs } from "../../lib/format";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview, type ParallelBranchResult } from "./helpers";
const RESULT_STATUS_TONE: Record<string, string> = {
succeeded: "bg-mint/15 text-mint",
partially_succeeded: "bg-amber/15 text-amber",
failed: "bg-coral/15 text-coral",
cancelled: "bg-overlay-strong text-fg-muted",
skipped: "bg-overlay-strong text-fg-muted",
};
function statusTone(status: string): string {
return RESULT_STATUS_TONE[status] ?? "bg-overlay-strong text-fg-muted";
}
function statusLabel(status: string): string {
if (!status) return "—";
return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, " ");
}
function shortSha(sha: string | null): string | null {
if (!sha) return null;
return sha.length > 8 ? sha.slice(0, 8) : sha;
}
function StatItem({
label,
value,
tone = "default",
}: {
label: string;
value: string | number;
tone?: "default" | "success" | "danger";
}) {
const toneClass =
tone === "success" ? "text-mint" : tone === "danger" ? "text-coral" : "text-fg";
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-[0.16em] text-fg-muted">
{label}
</span>
<span className={`font-mono text-xl tabular-nums ${toneClass}`}>{value}</span>
</div>
);
}
function ChildRow({
result,
stageHref,
}: {
result: ParallelBranchResult;
stageHref: string | null;
}) {
const sha = shortSha(result.headSha);
const tone = statusTone(result.status);
const inner = (
<>
<span
className={`inline-flex w-24 shrink-0 justify-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${tone}`}
>
{statusLabel(result.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-sm text-fg-3">
{result.id}
</span>
{sha && (
<span className="inline-flex items-center gap-1 font-mono text-xs text-fg-muted">
{sha}
</span>
)}
{stageHref && (
<ArrowTopRightOnSquareIcon
className="size-3.5 shrink-0 text-fg-muted transition-colors group-hover:text-fg-2"
aria-hidden="true"
/>
)}
</>
);
return (
<li className="flex items-center gap-3 px-4 py-2.5">
{stageHref ? (
<Link
to={stageHref}
className="group flex flex-1 items-center gap-3 rounded -m-1 p-1 transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
{inner}
</Link>
) : (
<span className="flex flex-1 items-center gap-3">{inner}</span>
)}
{result.headSha && (
<CopyButton value={result.headSha} label="Copy commit SHA" className="shrink-0" />
)}
</li>
);
}
export function ParallelChildren({
stage,
events,
runId,
allStages,
}: {
stage: Stage;
events: EventEnvelope[];
runId: string;
allStages: Stage[];
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
// Map node_id -> latest stage_id so we can deep-link branches.
const latestStageByNode = useMemo(() => {
const latest = new Map<string, Stage>();
for (const s of allStages) {
const prev = latest.get(s.nodeId);
if (!prev || s.visit > prev.visit) latest.set(s.nodeId, s);
}
return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id]));
}, [allStages]);
const items = overview.results.length > 0
? overview.results
: overview.branchCount && overview.branchCount > 0
? Array.from({ length: overview.branchCount }, (_, i) => ({
id: `branch ${i + 1}`,
status: "running",
headSha: null,
}))
: [];
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar
stage={stage}
trailing={
overview.joinPolicy ? (
<span className="inline-flex items-center rounded-full bg-overlay-strong px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-fg-2">
{overview.joinPolicy.replace(/_/g, " ")}
</span>
) : null
}
/>
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<StatItem label="Branches" value={overview.branchCount ?? "—"} />
<StatItem
label="Succeeded"
value={overview.successCount ?? (overview.isComplete ? 0 : "—")}
tone="success"
/>
<StatItem
label="Failed"
value={overview.failureCount ?? (overview.isComplete ? 0 : "—")}
tone={overview.failureCount && overview.failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
value={overview.durationMs != null ? formatDurationMs(overview.durationMs) : overview.isComplete ? "—" : "running"}
/>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Branches
</h3>
{items.length === 0 ? (
<p className="text-sm text-fg-muted">No branches recorded yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg bg-panel outline-1 -outline-offset-1 outline-line">
{items.map((result, i) => {
const stageId = latestStageByNode.get(result.id);
const href = stageId ? `/runs/${runId}/stages/${stageId}` : null;
return (
<ChildRow
key={`${result.id}-${i}`}
result={result}
stageHref={href}
/>
);
})}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useMemo, type ReactNode } from "react";
import { Marked } from "marked";
import { highlightJson } from "../event-debug";
export function DetailField({
label,
children,
mono = false,
}: {
label: string;
children: ReactNode;
mono?: boolean;
}) {
return (
<div>
<div className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-muted">
{label}
</div>
<div className={mono ? "font-mono text-sm text-fg-3" : "text-sm text-fg-3"}>
{children}
</div>
</div>
);
}
export function CodeBlock({ children }: { children: string }) {
return (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
{children || <span className="text-fg-muted">empty</span>}
</pre>
);
}
export function prettyJson(raw: string): { text: string; isJson: boolean } {
if (!raw || !raw.trim()) return { text: "", isJson: false };
try {
return { text: JSON.stringify(JSON.parse(raw), null, 2), isJson: true };
} catch {
return { text: raw, isJson: false };
}
}
export function JsonBlock({ value }: { value: string }) {
const pretty = useMemo(() => prettyJson(value), [value]);
const tokens = useMemo(
() => (pretty.isJson ? highlightJson(pretty.text) : null),
[pretty.isJson, pretty.text],
);
return (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
{!pretty.text ? (
<span className="text-fg-muted">empty</span>
) : (
tokens ?? pretty.text
)}
</pre>
);
}
const SAFE_HTTP_URL_RE = /^https?:\/\//i;
const SAFE_MAILTO_URL_RE = /^mailto:/i;
function isSafeMarkdownHref(href: string): boolean {
return (
SAFE_HTTP_URL_RE.test(href) ||
SAFE_MAILTO_URL_RE.test(href) ||
href.startsWith("#") ||
(href.startsWith("/") && !href.startsWith("//"))
);
}
const markedSafe = new Marked();
markedSafe.use({
async: false,
walkTokens(token) {
if (
(token.type === "link" || token.type === "image") &&
typeof token.href === "string" &&
!isSafeMarkdownHref(token.href)
) {
token.href = "";
}
},
renderer: {
html() {
return "";
},
},
});
export function Markdown({ content }: { content: string }) {
const html = useMemo(
() => markedSafe.parse(content, { async: false }) as string,
[content],
);
return (
<div
className="prose prose-sm max-w-none text-fg-3 prose-headings:text-fg-2 prose-strong:text-fg-2 prose-code:rounded prose-code:bg-overlay-strong prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.8em] prose-code:font-mono prose-code:text-fg-3 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-overlay-strong prose-pre:text-fg-3 prose-a:text-teal-500"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View file

@ -0,0 +1,79 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
debugCategory,
debugCategoryLabel,
debugCategoryTone,
} from "../event-debug";
import type { DebugCategory } from "../event-debug";
import { StageMetaBar } from "./meta-bar";
interface CategoryCount {
category: DebugCategory;
count: number;
}
export function summarizeEventCategories(events: EventEnvelope[]): CategoryCount[] {
const counts = new Map<DebugCategory, number>();
for (const event of events) {
if (!event.event) continue;
const cat = debugCategory(event.event);
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count);
}
export function StageSummary({
stage,
events,
}: {
stage: Stage;
events: EventEnvelope[];
}) {
const categories = summarizeEventCategories(events);
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
<dt className="text-fg-muted">Stage</dt>
<dd className="font-mono text-fg-3">{stage.name}</dd>
<dt className="text-fg-muted">Node</dt>
<dd className="font-mono text-fg-3">
{stage.nodeId}
{stage.visit > 1 && <span className="text-fg-muted"> · visit {stage.visit}</span>}
</dd>
<dt className="text-fg-muted">Stage ID</dt>
<dd className="font-mono text-fg-3">{stage.id}</dd>
</dl>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Events
</h3>
{categories.length === 0 ? (
<p className="text-sm text-fg-muted">No events recorded for this stage.</p>
) : (
<ul className="flex flex-wrap gap-2">
{categories.map(({ category, count }) => (
<li
key={category}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
>
<span>{debugCategoryLabel(category)}</span>
<span className="font-mono tabular-nums opacity-80">{count}</span>
</li>
))}
</ul>
)}
<p className="mt-3 text-xs text-fg-muted">
Switch to the Debug tab to inspect individual events.
</p>
</section>
</div>
);
}

View file

@ -0,0 +1,49 @@
import { PauseCircleIcon } from "@heroicons/react/20/solid";
import type { Stage } from "../stage-sidebar";
import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar";
import { useTickingNow } from "../../lib/time";
import { StageMetaBar } from "./meta-bar";
function formatHms(startedAt: string | null): string {
if (!startedAt) return "—";
const startMs = Date.parse(startedAt);
if (Number.isNaN(startMs)) return "—";
const totalSecs = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
const hours = Math.floor(totalSecs / 3600);
const minutes = Math.floor((totalSecs % 3600) / 60);
const seconds = totalSecs % 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
export function WaitStatus({ stage }: { stage: Stage }) {
const isActive = ACTIVE_STAGE_STATES.has(stage.status);
// Tick every second while waiting so the clock keeps moving.
useTickingNow(isActive);
const elapsed = isActive ? formatHms(stage.startedAt) : stage.duration;
const label = isActive ? "Waiting" : "Wait complete";
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="flex items-center justify-center rounded-lg bg-panel py-12 outline-1 -outline-offset-1 outline-line">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-fg-muted">
{isActive && (
<PauseCircleIcon className="size-3.5 text-amber" aria-hidden="true" />
)}
{label}
</div>
<div
className={`font-mono text-5xl tabular-nums ${
isActive ? "text-fg" : "text-fg-2"
}`}
>
{elapsed}
</div>
</div>
</section>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, type ComponentType } from "react";
import { useEffect, useRef, type ComponentType } from "react";
import { Link } from "react-router";
import type { StageState } from "@qltysh/fabro-api-client";
import type { StageHandler, StageState } from "@qltysh/fabro-api-client";
import {
ArrowPathIcon,
CheckCircleIcon,
@ -9,16 +9,26 @@ import {
PauseCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
import { Bars3BottomLeftIcon, DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import {
Bars3BottomLeftIcon,
BoltIcon,
DocumentTextIcon,
MapIcon,
PaperClipIcon,
} from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { ACTIVE_STAGE_STATES } from "../lib/stage-sidebar";
import { ACTIVE_STAGE_STATES, formatStageLabel } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
export interface Stage {
id: string;
name: string;
handler: StageHandler;
status: StageState;
duration: string;
dotId?: string;
nodeId: string;
visit: number;
startedAt: string | null;
}
export const statusConfig: Record<StageState, { icon: ComponentType<{ className?: string }>; color: string }> = {
@ -36,13 +46,12 @@ interface StageSidebarProps {
stages: Stage[];
runId: string;
selectedStageId?: string;
activeLink?: "settings" | "graph" | "logs";
activeLink?: "settings" | "source" | "logs" | "artifacts" | "events";
}
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
// Track when we first observed each running stage (for ticking timer)
const runningStartRef = useRef<Map<string, number>>(new Map());
const [, setTick] = useState(0);
// Track start times for running stages
useEffect(() => {
@ -62,16 +71,13 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
}, [stages]);
// Tick every second while any stage is running
useEffect(() => {
if (!stages.some((s) => ACTIVE_STAGE_STATES.has(s.status))) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [stages]);
const hasActive = stages.some((s) => ACTIVE_STAGE_STATES.has(s.status));
const now = useTickingNow(hasActive);
function stageDuration(stage: Stage): string {
if (ACTIVE_STAGE_STATES.has(stage.status)) {
const start = runningStartRef.current.get(stage.id);
if (start) return formatDurationSecs(Math.floor((Date.now() - start) / 1000));
if (start) return formatDurationSecs(Math.floor((now - start) / 1000));
return "0s";
}
return stage.duration;
@ -100,7 +106,7 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
}`}
>
<Icon className={`size-4 shrink-0 ${config.color} ${ACTIVE_STAGE_STATES.has(stage.status) ? "animate-spin" : ""}`} />
<span className="flex-1 truncate">{stage.name}</span>
<span className="flex-1 truncate">{formatStageLabel(stage)}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{stageDuration(stage)}</span>
</Link>
</li>
@ -115,28 +121,15 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
<ul className="mt-2 space-y-0.5">
<li>
<Link
to={`/runs/${runId}/settings`}
to={`/runs/${runId}/source`}
className={`${linkBase} ${
activeLink === "settings"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
Run Settings
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/graph`}
className={`${linkBase} ${
activeLink === "graph"
activeLink === "source"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<MapIcon className="size-4 shrink-0 text-fg-muted" />
Workflow Graph
Graph Source
</Link>
</li>
<li>
@ -152,6 +145,45 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
Run Logs
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/events`}
className={`${linkBase} ${
activeLink === "events"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<BoltIcon className="size-4 shrink-0 text-fg-muted" />
Run Events
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/artifacts`}
className={`${linkBase} ${
activeLink === "artifacts"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<PaperClipIcon className="size-4 shrink-0 text-fg-muted" />
Artifacts
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/settings`}
className={`${linkBase} ${
activeLink === "settings"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
Run Settings
</Link>
</li>
</ul>
</div>
</nav>

View file

@ -0,0 +1,138 @@
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
} from "react";
import { ApiError } from "../lib/api-client";
import { useInterruptRun, useSteerRun } from "../lib/mutations";
import { ErrorMessage } from "./ui";
export interface SteerBarProps {
runId: string;
}
export interface SteerBarHandle {
focus(): void;
}
export const SteerBar = forwardRef<SteerBarHandle, SteerBarProps>(function SteerBar(
{ runId },
ref,
) {
const [text, setText] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const steer = useSteerRun(runId);
const interrupt = useInterruptRun(runId);
const pending = steer.isMutating || interrupt.isMutating;
useImperativeHandle(ref, () => ({
focus() {
textareaRef.current?.focus();
},
}));
const trimmed = text.trim();
const canSend = trimmed.length > 0 && !pending;
async function sendSteering() {
if (!canSend) return;
setErrorMessage(null);
try {
await steer.trigger({ text: trimmed, interrupt: false });
setText("");
} catch (err) {
setErrorMessage(formatSteerError(err));
}
}
async function fireInterrupt() {
if (pending) return;
setErrorMessage(null);
try {
await interrupt.trigger();
} catch (err) {
setErrorMessage(formatInterruptError(err));
}
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
void sendSteering();
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendSteering();
}
}
return (
<form
onSubmit={handleSubmit}
aria-label="Steer running agent"
className="mx-auto max-w-4xl px-4 py-3 sm:px-6 lg:px-8"
>
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Steer the agent…"
rows={1}
maxLength={8192}
aria-label="Steering message"
className="flex-1 resize-none rounded-md bg-overlay px-3 py-2 text-sm text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500"
/>
<button
type="button"
onClick={() => void fireInterrupt()}
disabled={pending}
className="inline-flex shrink-0 items-center gap-2 rounded-md bg-overlay px-3 py-2 text-sm font-medium text-amber outline-1 -outline-offset-1 outline-amber/40 transition-colors hover:bg-amber/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber disabled:cursor-not-allowed disabled:opacity-60"
>
{interrupt.isMutating ? "Interrupting…" : "Interrupt"}
</button>
<button
type="submit"
disabled={!canSend}
className="inline-flex shrink-0 items-center justify-center rounded-md bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
>
{steer.isMutating ? "Sending…" : "Send"}
</button>
</div>
{errorMessage && (
<div className="mt-2">
<ErrorMessage message={errorMessage} />
</div>
)}
</form>
);
});
function formatSteerError(err: unknown): string {
if (err instanceof ApiError) {
const body = err.body as { code?: string; detail?: string } | null;
if (body?.code === "cli_agent_not_steerable") {
return "All running agent stages are CLI-mode and can't be steered.";
}
if (body?.code === "use_answer_endpoint") {
return "Run is blocked on a question; answer the question first.";
}
return body?.detail ?? err.message ?? "Steer failed.";
}
return "Steer failed; try again.";
}
function formatInterruptError(err: unknown): string {
if (err instanceof ApiError) {
const body = err.body as { detail?: string } | null;
return body?.detail ?? err.message ?? "Interrupt failed.";
}
return "Interrupt failed; try again.";
}

View file

@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import {
buildTerminalWebSocketUrl,
parseTerminalServerMessage,
sandboxStatusDetail,
TERMINAL_DOCK_CLEARANCE_CLASS,
terminalAccessCommandLabel,
} from "./terminal-view";
function locationLike(url: string): Location {
return new URL(url) as unknown as Location;
}
describe("terminal view helpers", () => {
test("builds ws URLs for local HTTP", () => {
expect(
buildTerminalWebSocketUrl(locationLike("http://127.0.0.1:4187/runs/run_1"), "run_1"),
).toBe("ws://127.0.0.1:4187/api/v1/runs/run_1/terminal");
});
test("builds wss URLs for HTTPS", () => {
expect(
buildTerminalWebSocketUrl(locationLike("https://fabro.example/runs/run/1"), "run/1"),
).toBe("wss://fabro.example/api/v1/runs/run%2F1/terminal");
});
test("parses terminal server control messages", () => {
expect(parseTerminalServerMessage('{"type":"ready"}')).toEqual({ type: "ready" });
expect(parseTerminalServerMessage('{"type":"error","message":"no sandbox"}')).toEqual({
type: "error",
message: "no sandbox",
});
expect(parseTerminalServerMessage('{"type":"unknown"}')).toBeNull();
expect(parseTerminalServerMessage("{")).toBeNull();
});
test("reserves space above the run steering bar", () => {
expect(TERMINAL_DOCK_CLEARANCE_CLASS).toContain("--fabro-interview-dock-clearance");
});
test("labels sandbox access commands by provider", () => {
expect(terminalAccessCommandLabel("daytona")).toBe("SSH");
expect(terminalAccessCommandLabel("docker")).toBe("Exec");
expect(terminalAccessCommandLabel("local")).toBeNull();
expect(terminalAccessCommandLabel(null)).toBeNull();
});
test("uses sandbox id as terminal status detail", () => {
expect(sandboxStatusDetail({
provider: "docker",
image: null,
snapshot: null,
runtime: {
id: "container-abc123",
working_directory: "/workspace",
repo_cloned: null,
clone_origin_url: null,
clone_branch: null,
},
}))
.toBe("container-abc123");
expect(sandboxStatusDetail({
provider: "daytona",
image: null,
snapshot: null,
runtime: {
id: "sandbox-name",
working_directory: "/workspace",
repo_cloned: null,
clone_origin_url: null,
clone_branch: null,
},
}))
.toBe("sandbox-name");
expect(sandboxStatusDetail({
provider: "docker",
image: null,
snapshot: null,
runtime: null,
})).toBe("docker");
expect(sandboxStatusDetail(null)).toBeNull();
});
});

View file

@ -0,0 +1,412 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { Terminal as XtermTerminal } from "@xterm/xterm";
import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit";
import {
ArrowPathIcon,
ArrowTopRightOnSquareIcon,
ClipboardDocumentIcon,
} from "@heroicons/react/20/solid";
import type { RunSandbox } from "@qltysh/fabro-api-client";
import { SECONDARY_BUTTON_CLASS, Tooltip } from "./ui";
import { ErrorState } from "./state";
import { useToast } from "./toast";
import { apiData, humanInTheLoopApi } from "../lib/api-client";
import { useRunState } from "../lib/queries";
const ICON_BUTTON_CLASS =
"inline-flex size-9 items-center justify-center rounded-lg text-fg-2 outline-1 -outline-offset-1 outline-white/10 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
export const TERMINAL_DOCK_CLEARANCE_CLASS =
"pb-[calc(0.125rem+var(--fabro-interview-dock-clearance,0px))]";
type ConnectionStatus = "connecting" | "ready" | "closed" | "error";
interface TerminalServerMessage {
type: "ready" | "error" | "closed";
message?: string;
}
const TERMINAL_BACKGROUND = "#05080F";
// Pin the cell to a whole-pixel height so xterm's fit math stays exact.
// fontSize × lineHeight = 13 × (19/13) = 19px → no sub-pixel rounding,
// no bottom-row clipping.
const TERMINAL_FONT_SIZE = 13;
const TERMINAL_CELL_HEIGHT_PX = 19;
const TERMINAL_LINE_HEIGHT = TERMINAL_CELL_HEIGHT_PX / TERMINAL_FONT_SIZE;
const TERMINAL_THEME = {
background: TERMINAL_BACKGROUND,
foreground: "#E6EDF3",
cursor: "#7AC4E5",
cursorAccent: "#05080F",
selectionBackground: "#1F4F73",
black: "#05080F",
red: "#FF6B6B",
green: "#5EE6A8",
yellow: "#FFC857",
blue: "#82AAFF",
magenta: "#C792EA",
cyan: "#7AC4E5",
white: "#D5DCE3",
brightBlack: "#4B5563",
brightRed: "#FF8B8B",
brightGreen: "#85F5C2",
brightYellow: "#FFD98A",
brightBlue: "#A4C4FF",
brightMagenta: "#E0B6FF",
brightCyan: "#A8DFF5",
brightWhite: "#FFFFFF",
};
export function buildTerminalWebSocketUrl(location: Location, runId: string): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}/api/v1/runs/${encodeURIComponent(runId)}/terminal`;
}
export function buildFullScreenTerminalUrl(runId: string): string {
return `/runs/${encodeURIComponent(runId)}/terminal`;
}
export function parseTerminalServerMessage(data: string): TerminalServerMessage | null {
try {
const parsed = JSON.parse(data);
if (!parsed || typeof parsed !== "object") return null;
const type = (parsed as { type?: unknown }).type;
if (type !== "ready" && type !== "error" && type !== "closed") return null;
const message = (parsed as { message?: unknown }).message;
return {
type,
message: typeof message === "string" ? message : undefined,
};
} catch {
return null;
}
}
export function terminalAccessCommandLabel(provider: string | null): string | null {
if (provider === "daytona") return "SSH";
if (provider === "docker") return "Exec";
return null;
}
function terminalAccessCommandCopiedMessage(provider: string | null): string {
return provider === "docker" ? "Docker exec command copied." : "SSH command copied.";
}
function terminalAccessCommandErrorMessage(provider: string | null): string {
return provider === "docker"
? "Could not copy Docker exec command."
: "Could not copy SSH command.";
}
export function sandboxStatusDetail(sandbox: RunSandbox | null | undefined): string | null {
return sandbox?.runtime?.id ?? sandbox?.provider ?? null;
}
function sendResize(socket: WebSocket | null, terminal: XtermTerminal | null) {
if (!socket || socket.readyState !== WebSocket.OPEN || !terminal) return;
socket.send(JSON.stringify({
type: "resize",
cols: terminal.cols,
rows: terminal.rows,
}));
}
function statusDotClasses(status: ConnectionStatus): string {
switch (status) {
case "ready":
return "bg-teal-500";
case "error":
return "bg-coral";
case "closed":
return "bg-fg-muted";
case "connecting":
return "bg-amber animate-pulse";
}
}
function statusLabel(status: ConnectionStatus): string {
switch (status) {
case "ready":
return "Connected";
case "error":
return "Error";
case "closed":
return "Closed";
case "connecting":
return "Connecting";
}
}
function StatusPill({
status,
detail,
}: {
status: ConnectionStatus;
detail: string | null;
}) {
return (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-2 rounded-full bg-overlay py-1 pr-3 pl-2 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10"
>
<span
className={`size-1.5 rounded-full ${statusDotClasses(status)}`}
aria-hidden="true"
/>
<span>{statusLabel(status)}</span>
{detail ? (
<>
<span className="text-fg-muted" aria-hidden="true">·</span>
<span className="max-w-72 truncate font-mono text-fg-3" title={detail}>
{detail}
</span>
</>
) : null}
</span>
);
}
export default function TerminalView({
runId,
leading,
chromeless = false,
}: {
runId: string;
leading?: React.ReactNode;
chromeless?: boolean;
}) {
const { push } = useToast();
const stateQuery = useRunState(runId);
const sandbox = stateQuery.data?.sandbox ?? null;
const provider = sandbox?.provider ?? null;
const sandboxDetail = sandboxStatusDetail(sandbox);
const accessCommandLabel = terminalAccessCommandLabel(provider);
const [connectionKey, setConnectionKey] = useState(0);
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null);
const terminalEl = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<XtermTerminal | null>(null);
const fitRef = useRef<XtermFitAddon | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const headingId = useMemo(() => `run-terminal-${runId}`, [runId]);
const reconnect = useCallback(() => {
setError(null);
setStatus("connecting");
setConnectionKey((key) => key + 1);
}, []);
const copyAccessCommand = useCallback(async () => {
if (!accessCommandLabel) return;
try {
const response = await apiData(() =>
humanInTheLoopApi.createRunSshAccess(runId, { ttl_minutes: 60 }),
);
await navigator.clipboard.writeText(response.command);
push({ message: terminalAccessCommandCopiedMessage(provider) });
} catch (err) {
push({
tone: "error",
message: err instanceof Error
? err.message
: terminalAccessCommandErrorMessage(provider),
});
}
}, [accessCommandLabel, runId, provider, push]);
useEffect(() => {
if (!terminalEl.current) return undefined;
let disposed = false;
let resizeObserver: ResizeObserver | null = null;
const textEncoder = new TextEncoder();
const disposables: Array<{ dispose: () => void }> = [];
async function connect() {
setStatus("connecting");
setError(null);
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !terminalEl.current) return;
const terminal = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: "\"JetBrains Mono\", ui-monospace, monospace",
fontSize: TERMINAL_FONT_SIZE,
lineHeight: TERMINAL_LINE_HEIGHT,
scrollback: 5000,
theme: TERMINAL_THEME,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalEl.current);
fitAddon.fit();
terminal.focus();
terminalRef.current = terminal;
fitRef.current = fitAddon;
const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId));
socket.binaryType = "arraybuffer";
socketRef.current = socket;
disposables.push(terminal.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(textEncoder.encode(data));
}
}));
socket.addEventListener("open", () => {
sendResize(socket, terminal);
});
socket.addEventListener("message", (event) => {
if (typeof event.data === "string") {
const message = parseTerminalServerMessage(event.data);
if (!message) return;
if (message.type === "ready") {
setStatus("ready");
return;
}
if (message.type === "closed") {
setStatus("closed");
return;
}
setStatus("error");
setError({
message: message.message ?? "Terminal session failed.",
recoverable: false,
});
return;
}
const bytes = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: event.data;
terminal.write(bytes);
});
socket.addEventListener("close", () => {
setStatus((current) => current === "error" ? current : "closed");
});
socket.addEventListener("error", () => {
setStatus("error");
setError({
message: "Terminal WebSocket connection failed.",
recoverable: true,
});
});
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
sendResize(socket, terminal);
});
resizeObserver.observe(terminalEl.current);
if (typeof document !== "undefined" && document.fonts?.ready) {
void document.fonts.ready.then(() => {
if (disposed) return;
fitAddon.fit();
sendResize(socket, terminal);
});
}
}
void connect();
return () => {
disposed = true;
resizeObserver?.disconnect();
for (const disposable of disposables) disposable.dispose();
socketRef.current?.send(JSON.stringify({ type: "close" }));
socketRef.current?.close();
socketRef.current = null;
terminalRef.current?.dispose();
terminalRef.current = null;
fitRef.current = null;
};
}, [connectionKey, runId]);
return (
<section
className="flex h-full min-h-0 flex-col"
aria-labelledby={headingId}
style={chromeless ? { backgroundColor: TERMINAL_BACKGROUND } : undefined}
>
<h2 id={headingId} className="sr-only">Terminal</h2>
{!chromeless && (
<div className="mb-2 flex shrink-0 flex-wrap items-center gap-3">
{leading}
<StatusPill status={status} detail={sandboxDetail} />
<div className="ml-auto flex items-center gap-2">
<Tooltip label="Open in new tab">
<a
href={buildFullScreenTerminalUrl(runId)}
target="_blank"
rel="noreferrer"
className={ICON_BUTTON_CLASS}
aria-label="Open terminal in new tab"
>
<ArrowTopRightOnSquareIcon
className="size-4"
aria-hidden="true"
/>
</a>
</Tooltip>
<Tooltip label="Reconnect">
<button
type="button"
className={ICON_BUTTON_CLASS}
onClick={reconnect}
aria-label="Reconnect terminal"
>
<ArrowPathIcon className="size-4" aria-hidden="true" />
</button>
</Tooltip>
{accessCommandLabel && (
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={() => void copyAccessCommand()}
aria-label={`Copy ${accessCommandLabel} command`}
>
<ClipboardDocumentIcon className="size-4" aria-hidden="true" />
{accessCommandLabel}
</button>
)}
</div>
</div>
)}
{error ? (
<div className="flex min-h-0 flex-1 items-center justify-center" role="alert">
<ErrorState
title="Terminal unavailable"
description={error.message}
onRetry={error.recoverable ? reconnect : undefined}
/>
</div>
) : chromeless ? (
<div ref={terminalEl} className="h-full min-h-0 p-3" />
) : (
<div
className="min-h-0 flex-1 overflow-hidden rounded border border-line pb-3"
style={{ backgroundColor: TERMINAL_BACKGROUND }}
>
<div ref={terminalEl} className="h-full min-h-0 p-3" />
</div>
)}
</section>
);
}

View file

@ -2,7 +2,9 @@
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useState } from "react";
import { useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
@ -15,7 +17,10 @@ export const PRIMARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
export const SECONDARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
export const DANGER_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-coral px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-coral/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coral disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral";
export function ErrorMessage({ message }: { message: string }) {
return (
@ -62,3 +67,109 @@ export function CopyButton({
</button>
);
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
pendingLabel,
cancelLabel = "Cancel",
pending = false,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
description: ReactNode;
confirmLabel: string;
pendingLabel?: string;
cancelLabel?: string;
pending?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<Dialog
open={open}
onClose={() => {
if (!pending) onCancel();
}}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" aria-hidden="true" />
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
<DialogPanel className="w-full max-w-md rounded-lg border border-line-strong bg-panel shadow-2xl shadow-black/40">
<div className="px-5 py-4">
<DialogTitle className="text-sm font-semibold text-fg">{title}</DialogTitle>
<div className="mt-2 text-sm text-fg-3">{description}</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className={SECONDARY_BUTTON_CLASS}
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
className={DANGER_BUTTON_CLASS}
>
{pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
</button>
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export function Tooltip({
label,
children,
}: {
label: React.ReactNode;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLSpanElement>(null);
const id = useId();
const rect = open ? triggerRef.current?.getBoundingClientRect() : null;
const portalTarget = typeof document === "undefined" ? null : document.body;
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
aria-describedby={open ? id : undefined}
className="inline-flex"
>
{children}
</span>
{rect && portalTarget
? createPortal(
<div
role="tooltip"
id={id}
style={{
top: rect.top - 6,
left: rect.left + rect.width / 2,
}}
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-panel px-2 py-1 text-xs text-fg-2 shadow-lg outline-1 -outline-offset-1 outline-line-strong"
>
{label}
</div>,
portalTarget,
)
: null}
</>
);
}

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { Run, RunStatus as ApiRunStatus } from "@qltysh/fabro-api-client";
import {
columnForStatus,
columnStatusDisplay,
@ -8,26 +9,75 @@ import {
runStatusDisplay,
} from "./runs";
function makeRun(overrides: Partial<Run> = {}): Run {
return {
id: "01ABC",
goal: "Fix the build",
title: "Fix the build",
workflow: { slug: "fix_build", name: "Fix Build" },
automation: null,
repository: { name: "myrepo", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: "running" },
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: null,
models: [],
source_directory: "/home/user/myrepo",
timestamps: {
created_at: "2026-04-08T12:00:00Z",
started_at: "2026-04-08T12:00:00Z",
last_event_at: null,
completed_at: null,
duration_ms: 65000,
elapsed_secs: 65,
},
billing: { total_usd_micros: 500000 },
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
...overrides,
};
}
function withStatus(status: ApiRunStatus): Pick<Run, "lifecycle"> {
return {
lifecycle: {
status,
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
};
}
describe("mapRunListItem", () => {
test("trusts shared server fields for board items", () => {
const summary = {
run_id: "01ABC",
goal: "## Fix the build",
title: "Server supplied title",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "paused", prior_block: null },
labels: {},
column: "running",
elapsed_secs: 65,
duration_ms: 65000,
total_usd_micros: 500000,
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
} as const;
const summary = makeRun({
title: "Server supplied title",
...withStatus({ kind: "paused", prior_block: null }),
pull_request: {
number: 123,
html_url: "https://github.com/fabro-sh/fabro/pull/123",
provider: "github",
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
});
const item = mapRunListItem(summary);
expect(item.id).toBe("01ABC");
expect(item.title).toBe("Server supplied title");
@ -37,27 +87,12 @@ describe("mapRunListItem", () => {
expect(item.elapsed).toBeDefined();
expect(item.column).toBe("running");
expect(item.lifecycleStatus).toBe("paused");
expect(item.number).toBe(123);
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/123");
});
test("uses a fallback title when the server title is blank", () => {
const summary = {
run_id: "01EMPTY",
goal: "",
title: "",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
labels: {},
column: "running",
elapsed_secs: null,
duration_ms: null,
total_usd_micros: null,
created_at: "2026-04-08T12:00:00Z",
start_time: null,
pending_control: null,
} as const;
const summary = makeRun({ id: "01EMPTY", goal: "", title: "" });
expect(mapRunListItem(summary).title).toBe("Untitled run");
});
@ -65,23 +100,18 @@ describe("mapRunListItem", () => {
describe("mapRunSummaryToRunItem", () => {
test("maps canonical run summary to RunItem", () => {
const summary = {
run_id: "01ABC",
goal: "Fix the build",
title: "Fix the build",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
duration_ms: 65000,
elapsed_secs: 65,
total_usd_micros: 500000,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
};
const summary = makeRun({
pull_request: {
html_url: "https://github.com/fabro-sh/fabro/pull/456",
number: 456,
provider: "github",
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
});
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01ABC");
expect(item.title).toBe("Fix the build");
@ -90,26 +120,29 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined();
expect(item.lifecycleStatus).toBe("running");
expect(item.number).toBe(456);
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/456");
});
test("handles missing optional fields", () => {
const summary = {
run_id: "01DEF",
goal: "",
title: "",
workflow_slug: null,
workflow_name: null,
const summary = makeRun({
id: "01DEF",
goal: "",
title: "",
workflow: { slug: null, name: "unknown" },
source_directory: null,
repository: { name: "unknown" },
status: { kind: "submitted" },
duration_ms: null,
elapsed_secs: null,
total_usd_micros: null,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: null,
pending_control: null,
};
repository: { name: "unknown", origin_url: null, provider: "unknown" },
...withStatus({ kind: "submitted" }),
timestamps: {
created_at: "2026-04-08T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
billing: null,
});
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01DEF");
expect(item.title).toBe("Untitled run");

View file

@ -1,8 +1,9 @@
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type {
RunListItem,
RunStatus as ApiRunStatus,
RunSummary,
import {
BoardColumn,
type BoardColumn as ApiBoardColumn,
type Run,
type RunStatus as ApiRunStatus,
} from "@qltysh/fabro-api-client";
export type CiStatus = "passing" | "failing" | "pending";
@ -24,6 +25,7 @@ export interface RunItem {
lifecycleStatus?: RunStatus | null;
lifecycleStatusLabel?: string;
number?: number;
pullRequestUrl?: string;
additions?: number;
deletions?: number;
checks?: CheckRun[];
@ -35,16 +37,30 @@ export interface RunItem {
sandboxId?: string;
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
createdAt?: string;
lastEventAt?: string;
}
export type ColumnStatus = "initializing" | "running" | "blocked" | "succeeded" | "failed";
export type ColumnStatus = ApiBoardColumn;
export const columnStatuses = [
BoardColumn.QUEUED,
BoardColumn.INITIALIZING,
BoardColumn.RUNNING,
BoardColumn.BLOCKED,
BoardColumn.SUCCEEDED,
BoardColumn.FAILED,
BoardColumn.ARCHIVED,
] as const satisfies readonly ColumnStatus[];
export const columnStatusDisplay: Record<ColumnStatus, { label: string; dot: string; text: string }> = {
queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" },
initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
succeeded: { label: "Succeeded", dot: "bg-teal-300", text: "text-teal-300" },
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
archived: { label: "Archived", dot: "bg-fg-muted", text: "text-fg-muted" },
};
export interface RunWithStatus extends RunItem {
@ -56,63 +72,51 @@ function displayRunTitle(title: string | null | undefined): string {
return title?.trim() ? title : "Untitled run";
}
function displayRepoName(name: string): string {
const slash = name.lastIndexOf("/");
return slash >= 0 ? name.slice(slash + 1) : name;
}
function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | null {
return status?.kind ?? null;
}
export function mapRunListItem(item: RunListItem): RunItem {
const lifecycleStatus = runStatusKind(item.status);
export function mapRunListItem(item: Run): RunItem {
const lifecycleStatus = item.lifecycle.archived ? "archived" : runStatusKind(item.lifecycle.status);
const runtime = item.sandbox?.runtime;
return {
id: item.run_id,
repo: item.repository.name,
id: item.id,
repo: displayRepoName(item.repository?.name ?? "unknown"),
title: displayRunTitle(item.title),
workflow: item.workflow_slug ?? item.workflow_name ?? "unknown",
column: item.column,
workflow: item.workflow.slug ?? item.workflow.name ?? "unknown",
column: columnForRun(item) ?? undefined,
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.status),
lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived),
number: item.pull_request?.number,
additions: item.pull_request?.additions,
deletions: item.pull_request?.deletions,
checks: item.pull_request?.checks?.map((c) => ({
name: c.name,
status: c.status,
duration: c.duration_secs != null ? formatDurationSecs(c.duration_secs) : undefined,
})),
elapsed: item.elapsed_secs != null ? formatElapsedSecs(item.elapsed_secs) : undefined,
resources: item.sandbox?.resources ? `${item.sandbox.resources.cpu} CPU / ${item.sandbox.resources.memory} GB` : undefined,
comments: item.pull_request?.comments,
question: item.question?.text,
sandboxId: item.sandbox?.id ?? undefined,
sandboxWorkingDirectory: item.sandbox?.working_directory ?? undefined,
pullRequestUrl: item.pull_request?.html_url,
elapsed: item.timestamps.elapsed_secs != null ? formatElapsedSecs(item.timestamps.elapsed_secs) : undefined,
resources: undefined,
question: item.current_question?.text,
sandboxId: runtime?.id ?? undefined,
sandboxWorkingDirectory: runtime?.working_directory ?? undefined,
sourceDirectory: item.source_directory ?? undefined,
createdAt: item.timestamps.created_at,
lastEventAt: item.timestamps.last_event_at ?? undefined,
};
}
export type { RunSummary };
export type { Run };
export type RunSummary = Run;
export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
const lifecycleStatus = runStatusKind(summary.status);
return {
id: summary.run_id,
repo: summary.repository.name,
title: displayRunTitle(summary.title),
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
sourceDirectory: summary.source_directory ?? undefined,
elapsed:
summary.elapsed_secs != null
? formatElapsedSecs(summary.elapsed_secs)
: summary.duration_ms != null
? formatElapsedSecs(summary.duration_ms / 1000)
: undefined,
};
export function mapRunSummaryToRunItem(summary: Run): RunItem {
return mapRunListItem(summary);
}
export function columnForStatus(status: ApiRunStatus | null | undefined): ColumnStatus | null {
switch (status?.kind) {
case "submitted":
case "queued":
return "queued";
case "starting":
return "initializing";
case "running":
@ -131,6 +135,11 @@ export function columnForStatus(status: ApiRunStatus | null | undefined): Column
}
}
export function columnForRun(run: Run): ColumnStatus | null {
if (run.lifecycle.archived) return "archived";
return columnForStatus(run.lifecycle.status);
}
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
if (checks.some((c) => c.status === "failure")) return "failing";
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
@ -170,8 +179,8 @@ export function isRunStatus(s: string): s is RunStatus {
return knownRunStatuses.has(s);
}
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined): string | undefined {
const kind = runStatusKind(status);
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined, archived = false): string | undefined {
const kind = archived ? "archived" : runStatusKind(status);
if (!kind) return undefined;
return runStatusDisplay[kind].label;
}

View file

@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { SWRConfig } from "swr";
import { installRoutes } from "./install-router";
import { apiFetcher } from "./lib/api-client";
import { resolveFabroMode } from "./mode";
import { routes } from "./router";
@ -26,7 +25,6 @@ createRoot(rootElement).render(
<StrictMode>
<SWRConfig
value={{
fetcher: apiFetcher,
revalidateOnFocus: false,
dedupingInterval: 2000,
shouldRetryOnError: false,

View file

@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import { useToast } from "../components/toast";
import { subscribeToRunEvents, type RunEventPayload } from "../lib/run-events";
import type { MutateFn } from "../lib/sse";
const NOOP_MUTATE = (() => undefined) as MutateFn;
const DEDUPE_WINDOW = 256;
export function useRunToasts(runId: string | undefined) {
const { push } = useToast();
const seenEventIdsRef = useRef(new Set<string>());
useEffect(() => {
if (!runId) return;
const seen = new Set<string>();
seenEventIdsRef.current = seen;
return subscribeToRunEvents(runId, NOOP_MUTATE, undefined, {
onEvent: (payload) => {
const dedupeId = eventDedupeId(payload);
if (dedupeId) {
if (seen.has(dedupeId)) return;
seen.add(dedupeId);
if (seen.size > DEDUPE_WINDOW) {
// Set iteration order is insertion order; drop the oldest.
const oldest = seen.values().next().value;
if (oldest !== undefined) seen.delete(oldest);
}
}
const message = steeringToastMessage(payload);
if (message) {
push({ message });
}
},
});
}, [push, runId]);
}
function eventDedupeId(payload: RunEventPayload): string | null {
if (typeof payload.id === "string") return payload.id;
if (typeof payload.seq === "number") return `seq:${payload.seq}`;
return null;
}
function steeringToastMessage(payload: RunEventPayload): string | null {
const props = payload.properties ?? {};
switch (payload.event) {
case "run.interrupt":
return "Agent interrupted.";
case "run.steer":
return "Steer accepted.";
case "agent.steering.injected":
return "Steer delivered.";
case "agent.steer.buffered":
return "Steer queued — will apply when an agent stage runs.";
case "agent.steer.dropped": {
const reason = props.reason;
if (reason === "queue_full") {
return "Steer rate limit reached; oldest queued steer dropped.";
}
if (reason === "run_ended") {
return "Run ended before queued steer(s) could apply.";
}
return null;
}
default:
return null;
}
}

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import type { AxiosAdapter, AxiosRequestConfig } from "axios";
import {
putInstallObjectStore,
@ -7,6 +8,52 @@ import {
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
import { generatedAxios } from "./lib/api-client";
type StubGeneratedResponse = {
status: number;
data?: unknown;
statusText?: string;
};
const originalAdapter = generatedAxios.defaults.adapter;
afterEach(() => {
generatedAxios.defaults.adapter = originalAdapter;
});
function stubGeneratedAxios(response: StubGeneratedResponse) {
const calls: AxiosRequestConfig[] = [];
generatedAxios.defaults.adapter = (async (config) => {
calls.push(config);
if (response.status >= 400) {
throw {
isAxiosError: true,
message: response.statusText ?? `HTTP ${response.status}`,
response: {
status: response.status,
statusText: response.statusText ?? "",
data: response.data,
headers: {},
},
};
}
return {
data: response.data,
status: response.status,
statusText: response.statusText ?? "",
headers: {},
config,
};
}) as AxiosAdapter;
return calls;
}
function headerValue(config: AxiosRequestConfig, name: string): string | undefined {
const headers = config.headers as { get?: (key: string) => unknown } | undefined;
const value = headers?.get?.(name);
return typeof value === "string" ? value : undefined;
}
describe("readInstallError", () => {
test("prefers the structured install error payload", async () => {
@ -39,11 +86,7 @@ describe("readInstallError", () => {
describe("install object-store requests", () => {
test("testInstallObjectStore posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
const calls = stubGeneratedAxios({ status: 200, data: { ok: true } });
await testInstallObjectStore("test-install-token", {
provider: "s3",
@ -53,13 +96,11 @@ describe("install object-store requests", () => {
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/object-store/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.headers).toEqual({
Authorization: "Bearer test-install-token",
"Content-Type": "application/json",
});
expect(calls[0]!.init?.body).toBe(
expect(calls[0]!.url).toBe("/install/object-store/test");
expect(calls[0]!.method).toBe("post");
expect(headerValue(calls[0]!, "Authorization")).toBe("Bearer test-install-token");
expect(headerValue(calls[0]!, "Content-Type")).toContain("application/json");
expect(calls[0]!.data).toBe(
JSON.stringify({
provider: "s3",
bucket: "fabro-data",
@ -70,24 +111,19 @@ describe("install object-store requests", () => {
});
test("putInstallObjectStore surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "Bucket is required.",
},
],
}),
stubGeneratedAxios({
status: 422,
statusText: "Unprocessable Entity",
data: {
errors: [
{
status: 422,
headers: { "Content-Type": "application/json" },
status: "422",
title: "Unprocessable Entity",
detail: "Bucket is required.",
},
),
)) as typeof fetch;
],
},
});
await expect(
putInstallObjectStore("test-install-token", { provider: "s3" }),
@ -97,41 +133,35 @@ describe("install object-store requests", () => {
describe("install sandbox requests", () => {
test("testInstallSandbox posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
const calls = stubGeneratedAxios({ status: 200, data: { ok: true } });
await testInstallSandbox("test-install-token", {
provider: "daytona",
api_key: "dtn_test",
api_key: "dtn_test",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/sandbox/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.body).toBe(
expect(calls[0]!.url).toBe("/install/sandbox/test");
expect(calls[0]!.method).toBe("post");
expect(calls[0]!.data).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_test" }),
);
});
test("putInstallSandbox surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "api_key is required for daytona",
},
],
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
),
)) as typeof fetch;
stubGeneratedAxios({
status: 422,
statusText: "Unprocessable Entity",
data: {
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "api_key is required for daytona",
},
],
},
});
await expect(
putInstallSandbox("test-install-token", { provider: "daytona" }),

View file

@ -11,6 +11,8 @@ import type {
InstallSessionResponse,
} from "@qltysh/fabro-api-client";
import { ApiError, apiData, installApi } from "./lib/api-client";
export type {
InstallFinishResponse,
InstallGithubAppManifestInput,
@ -62,139 +64,133 @@ export async function readInstallError(
return `${fallback} (${response.status})`;
}
type InstallRequestOptions = {
path: string;
method: "GET" | "POST" | "PUT";
body?: unknown;
errorFallback: string;
};
async function installRequest(
token: string,
opts: InstallRequestOptions,
): Promise<Response> {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
export function readInstallApiError(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
const detail = installErrorDetail(error.body);
if (detail) return detail;
return `${fallback} (${error.status})`;
}
const response = await fetch(opts.path, {
method: opts.method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (!response.ok) {
throw new Error(await readInstallError(response, opts.errorFallback));
}
return response;
return error instanceof Error ? error.message : fallback;
}
async function installJsonRequest<T>(
token: string,
opts: InstallRequestOptions,
function installErrorDetail(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const errors = (body as { errors?: unknown }).errors;
if (!Array.isArray(errors)) return null;
const first = errors[0];
if (!first || typeof first !== "object") return null;
const detail = (first as { detail?: unknown }).detail;
return typeof detail === "string" && detail.trim() ? detail : null;
}
function installOptions(token: string) {
return {
headers: { Authorization: `Bearer ${token}` },
};
}
async function installCall<T>(
fallback: string,
call: () => Promise<T>,
): Promise<T> {
const response = await installRequest(token, opts);
return response.json() as Promise<T>;
try {
return await call();
} catch (error) {
throw new Error(readInstallApiError(error, fallback));
}
}
export async function getInstallSession(token: string): Promise<InstallSessionResponse> {
return installJsonRequest<InstallSessionResponse>(token, {
path: "/install/session",
method: "GET",
errorFallback: "install session request failed",
});
return installCall("install session request failed", () =>
apiData(() => installApi.getInstallSession(installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function testInstallLlm(
token: string,
provider: InstallLlmProviderInput,
): Promise<void> {
await installRequest(token, {
path: "/install/llm/test",
method: "POST",
body: provider,
errorFallback: "install llm validation failed",
});
await installCall("install llm validation failed", () =>
apiData(() => installApi.testInstallLlmCredentials(provider, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function putInstallLlm(
token: string,
providers: InstallLlmProviderInput[],
): Promise<void> {
await installRequest(token, {
path: "/install/llm",
method: "PUT",
body: { providers },
errorFallback: "install llm request failed",
});
await installCall("install llm request failed", () =>
apiData(() => installApi.putInstallLlm({ providers }, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function putInstallServer(token: string, canonicalUrl: string): Promise<void> {
await installRequest(token, {
path: "/install/server",
method: "PUT",
body: { canonical_url: canonicalUrl },
errorFallback: "install server request failed",
});
await installCall("install server request failed", () =>
apiData(() => installApi.putInstallServer({ canonical_url: canonicalUrl }, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function testInstallObjectStore(
token: string,
input: InstallObjectStoreInput,
): Promise<void> {
await installRequest(token, {
path: "/install/object-store/test",
method: "POST",
body: input,
errorFallback: "install object store validation failed",
});
await installCall("install object store validation failed", () =>
apiData(() => installApi.testInstallObjectStore(input, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function putInstallObjectStore(
token: string,
input: InstallObjectStoreInput,
): Promise<void> {
await installRequest(token, {
path: "/install/object-store",
method: "PUT",
body: input,
errorFallback: "install object store request failed",
});
await installCall("install object store request failed", () =>
apiData(() => installApi.putInstallObjectStore(input, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function testInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox/test",
method: "POST",
body: input,
errorFallback: "install sandbox validation failed",
});
await installCall("install sandbox validation failed", () =>
apiData(() => installApi.testInstallSandbox(input, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function putInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox",
method: "PUT",
body: input,
errorFallback: "install sandbox request failed",
});
await installCall("install sandbox request failed", () =>
apiData(() => installApi.putInstallSandbox(input, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function testInstallGithubToken(
token: string,
githubToken: string,
): Promise<string> {
const body = await installJsonRequest<{ username: string }>(token, {
path: "/install/github/token/test",
method: "POST",
body: { token: githubToken },
errorFallback: "install github token validation failed",
});
const body = await installCall("install github token validation failed", () =>
apiData(() => installApi.testInstallGithubToken({ token: githubToken }, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
return body.username;
}
@ -203,30 +199,31 @@ export async function putInstallGithubToken(
githubToken: string,
username: string,
): Promise<void> {
await installRequest(token, {
path: "/install/github/token",
method: "PUT",
body: { token: githubToken, username },
errorFallback: "install github token request failed",
});
await installCall("install github token request failed", () =>
apiData(() => installApi.putInstallGithubToken(
{ token: githubToken, username },
installOptions(token),
), {
redirectOnUnauthorized: false,
}),
);
}
export async function createInstallGithubAppManifest(
token: string,
input: InstallGithubAppManifestInput,
): Promise<InstallGithubAppManifestResponse> {
return installJsonRequest<InstallGithubAppManifestResponse>(token, {
path: "/install/github/app/manifest",
method: "POST",
body: input,
errorFallback: "install github app manifest request failed",
});
return installCall("install github app manifest request failed", () =>
apiData(() => installApi.createInstallGithubAppManifest(input, installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}
export async function finishInstall(token: string): Promise<InstallFinishResponse> {
return installJsonRequest<InstallFinishResponse>(token, {
path: "/install/finish",
method: "POST",
errorFallback: "install finish request failed",
});
return installCall("install finish request failed", () =>
apiData(() => installApi.finishInstall(installOptions(token)), {
redirectOnUnauthorized: false,
}),
);
}

View file

@ -1,8 +1,10 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import type { AxiosAdapter } from "axios";
import { MemoryRouter, Route, Routes } from "react-router";
import TestRenderer, { act } from "react-test-renderer";
import InstallApp from "./install-app";
import { generatedAxios } from "./lib/api-client";
const INSTALL_ERROR_MESSAGE =
"GitHub App setup failed before Fabro could save the app credentials. Continue again to retry the callback.";
@ -20,6 +22,8 @@ const SESSION_RESPONSE = {
prefill: INSTALL_PREFILL,
};
const originalAdapter = generatedAxios.defaults.adapter;
type TestWindow = {
history: {
state: unknown;
@ -121,6 +125,34 @@ function findOptionButton(
return button;
}
function useInstallFetchMock(fetchMock: typeof fetch) {
generatedAxios.defaults.adapter = (async (config) => {
const response = await fetchMock(config.url ?? "", {
method: config.method?.toUpperCase(),
headers: config.headers as HeadersInit,
body: config.data as BodyInit | undefined,
signal: config.signal,
});
const text = response.status === 204 ? "" : await response.text();
const data = text ? JSON.parse(text) : undefined;
const axiosResponse = {
data,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
config,
};
if (response.status >= 400) {
throw {
isAxiosError: true,
message: response.statusText || `HTTP ${response.status}`,
response: axiosResponse,
};
}
return axiosResponse;
}) as AxiosAdapter;
}
async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@ -138,6 +170,7 @@ async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
describe("InstallApp", () => {
afterEach(() => {
generatedAxios.defaults.adapter = originalAdapter;
delete (globalThis as { window?: unknown }).window;
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
mock.restore();
@ -165,7 +198,7 @@ describe("InstallApp", () => {
}),
);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow(
"https://fabro.example.com/install/github?error=github-app-manifest-conversion-failed",
@ -235,7 +268,7 @@ describe("InstallApp", () => {
}),
);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow(
"https://fabro.example.com/install/github/done?token=test-install-token",
@ -325,7 +358,7 @@ describe("InstallApp", () => {
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -421,7 +454,7 @@ describe("InstallApp", () => {
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -496,7 +529,7 @@ describe("InstallApp", () => {
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -562,7 +595,7 @@ describe("InstallApp", () => {
),
),
);
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -659,7 +692,7 @@ describe("InstallApp", () => {
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -753,7 +786,7 @@ describe("InstallApp", () => {
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
@ -841,7 +874,7 @@ describe("InstallApp", () => {
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");

View file

@ -2,19 +2,19 @@ import { describe, expect, test } from "bun:test";
import { getVisibleNavigation } from "./app-shell";
describe("getVisibleNavigation", () => {
test("shows all nav items in demo mode", () => {
test("shows all nav items in demo mode with Automations first", () => {
const items = getVisibleNavigation(true);
const names = items.map((i) => i.name);
expect(names).toContain("Workflows");
expect(names[0]).toBe("Automations");
expect(names).toContain("Runs");
expect(names).toContain("Insights");
expect(names).toContain("Settings");
});
test("hides Workflows and Insights in production mode", () => {
test("hides Automations and Insights in production mode", () => {
const items = getVisibleNavigation(false);
const names = items.map((i) => i.name);
expect(names).not.toContain("Workflows");
expect(names).not.toContain("Automations");
expect(names).not.toContain("Insights");
expect(names).toContain("Runs");
expect(names).toContain("Settings");

View file

@ -13,7 +13,7 @@ import {
ChartBarIcon,
Cog6ToothIcon,
PlayIcon,
RectangleStackIcon,
SparklesIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
@ -24,7 +24,7 @@ import { useToggleDemoMode } from "../lib/mutations";
import { useAuthMe } from "../lib/queries";
const allNavigation = [
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
{ name: "Automations", href: "/automations", icon: SparklesIcon, demoOnly: true },
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
@ -68,6 +68,9 @@ export default function AppShell() {
const headerExtra = handle?.headerExtra;
const hideHeader = matches.some((m) => (m.handle as { hideHeader?: boolean } | undefined)?.hideHeader);
const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide);
const fullHeight = matches.some(
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
);
const maxWidth = wide ? "" : "max-w-5xl";
async function toggleDemoMode() {
@ -77,8 +80,16 @@ export default function AppShell() {
return (
<DemoModeProvider value={demoMode}>
<ToastProvider>
<div className="isolate min-h-full">
<Disclosure as="nav" className="bg-panel">
<div
className={classNames(
"isolate",
fullHeight ? "flex h-dvh flex-col" : "min-h-full",
)}
>
<Disclosure
as="nav"
className={classNames("bg-panel", fullHeight && "shrink-0")}
>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center">
@ -140,6 +151,14 @@ export default function AppShell() {
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Link
to="/profile"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Profile
</Link>
</MenuItem>
<MenuItem>
<form method="POST" action="/auth/logout">
<button
@ -226,8 +245,15 @@ export default function AppShell() {
</button>
</div>
</div>
{provider !== "tailscale" && (
<div className="mt-3 space-y-1 px-2">
<div className="mt-3 space-y-1 px-2">
<DisclosureButton
as={Link}
to="/profile"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
>
Profile
</DisclosureButton>
{provider !== "tailscale" && (
<form method="POST" action="/auth/logout">
<DisclosureButton
as="button"
@ -237,14 +263,19 @@ export default function AppShell() {
Sign out
</DisclosureButton>
</form>
</div>
)}
)}
</div>
</div>
</DisclosurePanel>
</Disclosure>
{!hideHeader && (
<header className="relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:border-b after:border-line-strong">
<header
className={classNames(
"relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:border-b after:border-line-strong",
fullHeight && "shrink-0",
)}
>
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
<div className="flex items-center">
<h1 className="text-xl font-semibold tracking-tight text-fg">
@ -255,8 +286,13 @@ export default function AppShell() {
</div>
</header>
)}
<main>
<div className={`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`}>
<main className={fullHeight ? "min-h-0 flex-1" : undefined}>
<div
className={classNames(
`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`,
fullHeight && "box-border flex h-full min-h-0 flex-col",
)}
>
<Outlet />
</div>
</main>

View file

@ -2,11 +2,11 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
import {
ApiError,
apiFetcher,
apiNullableFetcher,
apiPaginatedFetcher,
apiRequest,
apiData,
extractRequestId,
fetchAllPages,
generatedAxios,
stageArtifactDownloadUrl,
} from "./api-client";
afterEach(() => {
@ -14,105 +14,116 @@ afterEach(() => {
delete (globalThis as { window?: unknown }).window;
});
describe("apiRequest", () => {
test("includes credentials on API requests", async () => {
const fetchMock = mock(() => Promise.resolve(new Response("{}", { status: 200 })));
globalThis.fetch = fetchMock as typeof fetch;
function axiosFailure({
status,
statusText,
data,
headers = {},
}: {
status: number;
statusText?: string;
data?: unknown;
headers?: Record<string, string>;
}) {
return {
isAxiosError: true,
message: statusText ?? `HTTP ${status}`,
response: {
status,
statusText: statusText ?? "",
data,
headers,
},
};
}
await apiRequest("/api/v1/runs/run-1");
expect(fetchMock).toHaveBeenCalledWith("/api/v1/runs/run-1", {
credentials: "include",
headers: undefined,
});
describe("generated Axios adapter", () => {
test("uses same-origin requests with browser credentials", () => {
expect(generatedAxios.defaults.baseURL).toBe("");
expect(generatedAxios.defaults.withCredentials).toBe(true);
});
test("401 responses throw a typed ApiError", async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ errors: [{ detail: "Request ID: req_401" }] }), {
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}),
),
);
globalThis.fetch = fetchMock as typeof fetch;
await expect(apiRequest("/api/v1/auth/me")).rejects.toMatchObject({
status: 401,
requestId: "req_401",
});
});
});
describe("apiFetcher", () => {
test("throws ApiError with status, body, and request id on non-2xx responses", async () => {
test("normalizes generated client failures into ApiError", async () => {
const body = {
errors: [{ status: "500", title: "Internal", request_id: "req_500" }],
errors: [{
status: "500",
title: "Internal",
detail: "Database is unavailable.",
request_id: "body-req",
}],
};
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify(body), {
status: 500,
statusText: "Internal Server Error",
headers: { "Content-Type": "application/json" },
}),
),
);
globalThis.fetch = fetchMock as typeof fetch;
try {
await apiFetcher("/api/v1/runs/run-1/files");
throw new Error("expected apiFetcher to reject");
await apiData(() =>
Promise.reject(
axiosFailure({
status: 500,
statusText: "Internal Server Error",
data: body,
headers: { "x-request-id": "header-req" },
}),
),
);
throw new Error("expected apiData to reject");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
status: 500,
message: "Internal Server Error",
requestId: "req_500",
message: "Database is unavailable.",
requestId: "header-req",
body,
});
}
});
});
describe("apiNullableFetcher", () => {
test("returns null only for explicit availability statuses", async () => {
const fetchMock = mock(() => Promise.resolve(new Response("", { status: 501 })));
globalThis.fetch = fetchMock as typeof fetch;
test("redirects normal authenticated 401 responses to login", async () => {
const location = { href: "" };
(globalThis as unknown as { window: { location: typeof location } }).window = {
location,
};
await expect(apiNullableFetcher("/api/v1/runs/run-1/files")).resolves.toBeNull();
await expect(() =>
apiData(() => Promise.reject(axiosFailure({ status: 401 }))),
).toThrow(ApiError);
expect(location.href).toBe("/login");
});
test("can suppress login redirects for login and install calls", async () => {
const location = { href: "" };
(globalThis as unknown as { window: { location: typeof location } }).window = {
location,
};
await expect(() =>
apiData(
() => Promise.reject(axiosFailure({ status: 401 })),
{ redirectOnUnauthorized: false },
),
).toThrow(ApiError);
expect(location.href).toBe("");
});
});
describe("apiPaginatedFetcher", () => {
describe("fetchAllPages", () => {
test("preserves first-page extras and stops at the page cap", async () => {
const warnMock = mock(() => {});
const originalWarn = console.warn;
console.warn = warnMock;
let calls = 0;
const fetchMock = mock(() => {
calls += 1;
return Promise.resolve(
new Response(
JSON.stringify({
try {
const result = await fetchAllPages<{ id: string }, { columns: { id: string; name: string }[] }>(
"board runs",
async () => {
calls += 1;
return {
columns: [{ id: "running", name: "Running" }],
data: [{ id: `run-${calls}` }],
meta: { has_more: true },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const result = await apiPaginatedFetcher<{ id: string }, { columns: { id: string; name: string }[] }>(
"/api/v1/boards/runs",
};
},
);
expect(result.columns).toEqual([{ id: "running", name: "Running" }]);
@ -125,6 +136,16 @@ describe("apiPaginatedFetcher", () => {
});
});
describe("stageArtifactDownloadUrl", () => {
test("builds the download href through generated client metadata", async () => {
await expect(
stageArtifactDownloadUrl("run 1", "stage@1", "logs/output.txt", 2),
).resolves.toBe(
"/api/v1/runs/run%201/stages/stage%401/artifacts/download?filename=logs%2Foutput.txt&retry=2",
);
});
});
describe("extractRequestId", () => {
test("supports top-level, error-level, and detail-embedded request ids", () => {
expect(extractRequestId({ request_id: "top" })).toBe("top");

View file

@ -1,7 +1,23 @@
export interface ApiOptions {
init?: RequestInit;
request?: Request;
}
import axios, {
isAxiosError,
type AxiosPromise,
type AxiosResponse,
type RawAxiosRequestConfig,
} from "axios";
import {
AuthApi,
Configuration,
HumanInTheLoopApi,
InsightsApi,
InstallApi,
RunInternalsApi,
RunInternalsApiAxiosParamCreator,
RunOutputsApi,
RunsApi,
SettingsApi,
SystemApi,
WorkflowsApi,
} from "@qltysh/fabro-api-client";
export interface PaginatedEnvelope<T> {
data: T[];
@ -32,13 +48,75 @@ export class ApiError extends Error {
}
}
const API_PREFIX = "/api/v1";
interface ApiCallOptions {
redirectOnUnauthorized?: boolean;
}
const PAGINATED_API_MAX_PAGES = 50;
const PAGINATED_API_MAX_ITEMS = 5000;
export function apiPath(path: string): string {
return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`;
}
export const generatedAxios = axios.create({
baseURL: "",
withCredentials: true,
});
export const generatedApiConfiguration = new Configuration({
basePath: "",
baseOptions: {
withCredentials: true,
},
});
export const authApi = new AuthApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const humanInTheLoopApi = new HumanInTheLoopApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const insightsApi = new InsightsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const installApi = new InstallApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const runInternalsApi = new RunInternalsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const runOutputsApi = new RunOutputsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const runsApi = new RunsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const settingsApi = new SettingsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const systemApi = new SystemApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const workflowsApi = new WorkflowsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export function isNotAvailable(status: number): boolean {
return status === 404 || status === 501;
@ -65,106 +143,106 @@ export function extractRequestId(body: unknown): string | null {
return null;
}
function requestIdFromHeaders(headers: Headers): string | null {
function requestIdFromHeaders(headers: unknown): string | null {
return (
headers.get("x-request-id") ??
headers.get("x-fabro-request-id") ??
headers.get("request-id")
headerValue(headers, "x-request-id")
?? headerValue(headers, "x-fabro-request-id")
?? headerValue(headers, "request-id")
);
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
const text = await response.text().catch(() => "");
if (!text) return null;
if (contentType.includes("json")) {
try {
return JSON.parse(text);
} catch {
return text;
}
function headerValue(headers: unknown, name: string): string | null {
if (!headers || typeof headers !== "object") return null;
const getter = (headers as { get?: (key: string) => unknown }).get;
if (typeof getter === "function") {
const value = getter.call(headers, name);
if (typeof value === "string") return value;
}
try {
return JSON.parse(text);
} catch {
return text;
const wanted = name.toLowerCase();
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (key.toLowerCase() !== wanted) continue;
if (typeof value === "string") return value;
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
}
return null;
}
async function apiErrorFromResponse(response: Response): Promise<ApiError> {
const body = await parseResponseBody(response);
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body);
function apiErrorFromAxios(error: unknown): ApiError | null {
if (!isAxiosError(error) || !error.response) return null;
const { response } = error;
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(response.data);
return new ApiError({
status: response.status,
message: response.statusText || `HTTP ${response.status}`,
message: extractErrorDetail(response.data) ?? (response.statusText || `HTTP ${response.status}`),
requestId,
body,
body: response.data ?? null,
});
}
export async function apiRequest(path: string, options?: ApiOptions): Promise<Response> {
const { init, request } = options ?? {};
const response = await fetch(apiPath(path), {
...init,
credentials: "include",
headers: init?.headers,
...(request?.signal ? { signal: request.signal } : {}),
});
function extractErrorDetail(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const errors = (body as Record<string, unknown>).errors;
if (!Array.isArray(errors) || errors.length === 0) return null;
if (response.status === 401) {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
throw await apiErrorFromResponse(response);
const first = errors[0];
if (!first || typeof first !== "object") return null;
const detail = (first as Record<string, unknown>).detail;
return typeof detail === "string" && detail.length > 0 ? detail : null;
}
function redirectToLogin(error: ApiError, options: ApiCallOptions) {
if (error.status !== 401 || options.redirectOnUnauthorized === false) return;
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return response;
}
export async function apiFetcher<T>(key: string): Promise<T> {
const response = await apiRequest(key);
if (!response.ok) {
throw await apiErrorFromResponse(response);
export async function apiData<T>(
call: () => AxiosPromise<T>,
options: ApiCallOptions = {},
): Promise<T> {
try {
const response = await call();
return response.data;
} catch (error) {
const apiError = apiErrorFromAxios(error);
if (!apiError) throw error;
redirectToLogin(apiError, options);
throw apiError;
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export async function apiTextFetcher(key: string): Promise<string> {
const response = await apiRequest(key);
if (!response.ok) {
throw await apiErrorFromResponse(response);
export async function apiResponse<T>(
call: () => AxiosPromise<T>,
options: ApiCallOptions = {},
): Promise<AxiosResponse<T>> {
try {
return await call();
} catch (error) {
const apiError = apiErrorFromAxios(error);
if (!apiError) throw error;
redirectToLogin(apiError, options);
throw apiError;
}
return response.text();
}
export async function apiNullableFetcher<T>(key: string): Promise<T | null> {
const response = await apiRequest(key);
if (isNotAvailable(response.status)) return null;
if (!response.ok) {
throw await apiErrorFromResponse(response);
export async function apiNullableData<T>(
call: () => AxiosPromise<T>,
): Promise<T | null> {
try {
return await apiData(call);
} catch (error) {
if (error instanceof ApiError && isNotAvailable(error.status)) return null;
throw error;
}
return response.json() as Promise<T>;
}
export async function apiNullableTextFetcher(key: string): Promise<string | null> {
const response = await apiRequest(key);
if (isNotAvailable(response.status)) return null;
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.text();
}
function paginatedApiPath(key: string, limit: number, offset: number): string {
const url = new URL(apiPath(key), "http://fabro.local");
url.searchParams.set("page[limit]", String(limit));
url.searchParams.set("page[offset]", String(offset));
return `${url.pathname}${url.search}`;
}
export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
key: string,
export async function fetchAllPages<TItem, TExtra extends object = {}>(
label: string,
loadPage: (limit: number, offset: number) => Promise<PaginatedEnvelope<TItem> & TExtra>,
): Promise<PaginatedEnvelope<TItem> & TExtra> {
const limit = 100;
let offset = 0;
@ -173,12 +251,7 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
let pagesLoaded = 0;
while (true) {
const response = await apiRequest(paginatedApiPath(key, limit, offset));
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
const page = (await response.json()) as PaginatedEnvelope<TItem> & TExtra;
const page = await loadPage(limit, offset);
if (extras == null) {
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
Record<string, unknown>;
@ -199,12 +272,12 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
}
if (
pagesLoaded >= PAGINATED_API_MAX_PAGES ||
pageItems.length < page.data.length ||
data.length >= PAGINATED_API_MAX_ITEMS
pagesLoaded >= PAGINATED_API_MAX_PAGES
|| pageItems.length < page.data.length
|| data.length >= PAGINATED_API_MAX_ITEMS
) {
console.warn(
`Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
`Stopped paginated API fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
);
return {
...(extras ?? ({} as TExtra)),
@ -217,20 +290,62 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
}
}
export async function apiJsonMutation<TResponse, TArg = unknown>(
key: string,
{ arg }: { arg: TArg },
): Promise<TResponse> {
const response = await apiRequest(key, {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: arg === undefined ? undefined : JSON.stringify(arg),
},
});
if (!response.ok) {
throw await apiErrorFromResponse(response);
export async function fetchAllStageEvents<TItem extends { seq: number }>(
label: string,
loadPage: (sinceSeq: number, limit: number) => Promise<PaginatedEnvelope<TItem>>,
): Promise<TItem[]> {
const PAGE_LIMIT = 1000;
const MAX_PAGES = 50;
const data: TItem[] = [];
let sinceSeq = 1;
let pagesLoaded = 0;
while (true) {
const page = await loadPage(sinceSeq, PAGE_LIMIT);
pagesLoaded += 1;
if (page.data.length === 0) {
if (page.meta.has_more) {
console.warn(
`Stage events fetch for ${label} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`,
);
}
return data;
}
data.push(...page.data);
if (!page.meta.has_more) return data;
if (pagesLoaded >= MAX_PAGES) {
console.warn(
`Stopped stage events fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
);
return data;
}
const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1);
if (highestSeq < sinceSeq) {
console.warn(
`Stage events fetch for ${label} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`,
);
return data;
}
sinceSeq = highestSeq + 1;
}
if (response.status === 204) return undefined as TResponse;
return response.json() as Promise<TResponse>;
}
export function requestSignalOptions(request?: Request): RawAxiosRequestConfig {
return request?.signal ? { signal: request.signal } : {};
}
export async function stageArtifactDownloadUrl(
id: string,
stageId: string,
filename: string,
retry: number,
): Promise<string> {
const requestArgs = await RunInternalsApiAxiosParamCreator(
generatedApiConfiguration,
).getStageArtifact(id, stageId, filename, retry);
return `${generatedApiConfiguration.basePath ?? ""}${requestArgs.url}`;
}

View file

@ -4,7 +4,12 @@ import {
shouldRefreshBoardForEvent,
subscribeToBoardEvents,
} from "./board-events";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import type { EventSourceLike, SseKey } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
@ -21,6 +26,14 @@ class FakeEventSource {
}
}
class FakeBroadcastChannel implements BroadcastChannelLike {
onmessage: ((event: { data: unknown }) => void) | null = null;
postMessage() {}
close() {}
}
describe("shouldRefreshBoardForEvent", () => {
test("refreshes board for run and interview status changes only", () => {
expect(shouldRefreshBoardForEvent("run.running")).toBe(true);
@ -31,11 +44,47 @@ describe("shouldRefreshBoardForEvent", () => {
});
describe("subscribeToBoardEvents", () => {
test("shares one source and invalidates the board runs key", () => {
test("coordinated mode shares one global source and invalidates the board runs key", async () => {
const source = new FakeEventSource();
const created: string[] = [];
const keys: string[] = [];
const mutate = (key: string) => {
const keys: SseKey[] = [];
const coordinator = createCoordinator((url) => {
created.push(url);
return source;
});
const mutate = (key: SseKey) => {
keys.push(key);
return Promise.resolve();
};
const firstCleanup = subscribeToBoardEvents(mutate, () => {
throw new Error("source should be created by coordinator");
}, { debounceMs: 0, coordinator });
const secondCleanup = subscribeToBoardEvents(mutate, () => {
throw new Error("source should be reused");
}, { debounceMs: 0, coordinator });
await waitFor(() => created.length === 1);
keys.length = 0;
source.emit({ event: "run.running" });
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([queryKeys.boards.runs(false), queryKeys.boards.runs(true)]);
firstCleanup();
expect(source.closed).toBe(false);
secondCleanup();
expect(source.closed).toBe(true);
coordinator.close();
});
test("fallback mode preserves the existing shared board EventSource", () => {
const source = new FakeEventSource();
const created: string[] = [];
const keys: SseKey[] = [];
const coordinator = createFallbackCoordinator();
const mutate = (key: SseKey) => {
keys.push(key);
return Promise.resolve();
};
@ -43,19 +92,52 @@ describe("subscribeToBoardEvents", () => {
const firstCleanup = subscribeToBoardEvents(mutate, (url) => {
created.push(url);
return source;
}, { debounceMs: 0 });
}, { debounceMs: 0, coordinator });
const secondCleanup = subscribeToBoardEvents(mutate, () => {
throw new Error("source should be reused");
}, { debounceMs: 0 });
}, { debounceMs: 0, coordinator });
source.emit({ event: "run.running" });
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([queryKeys.boards.runs()]);
expect(keys).toEqual([queryKeys.boards.runs(false), queryKeys.boards.runs(true)]);
firstCleanup();
expect(source.closed).toBe(false);
secondCleanup();
expect(source.closed).toBe(true);
coordinator.close();
});
});
function createCoordinator(eventSourceFactory: (url: string) => EventSourceLike) {
return createCrossTabSseCoordinator({
tabId: "board-test",
channelFactory: () => new FakeBroadcastChannel(),
eventSourceFactory,
addVisibilityChangeListener: () => () => {},
addPagehideListener: () => () => {},
timing: {
heartbeatMs: 10,
leaderStaleMs: 50,
electionJitterMs: 0,
},
});
}
function createFallbackCoordinator() {
return createCrossTabSseCoordinator({
channelFactory: () => {
throw new Error("BroadcastChannel unavailable");
},
});
}
async function waitFor(condition: () => boolean, timeoutMs = 200) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error("condition did not become true before timeout");
}

View file

@ -1,6 +1,10 @@
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
@ -11,6 +15,11 @@ import {
type SharedEventSubscription,
} from "./sse";
interface BoardEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
}
const BOARD_STATUS_EVENTS = new Set([
"run.submitted",
"run.queued",
@ -25,6 +34,7 @@ const BOARD_STATUS_EVENTS = new Set([
"run.failed",
"run.archived",
"run.unarchived",
"run.title.updated",
"interview.started",
"interview.completed",
"interview.timeout",
@ -41,23 +51,40 @@ export function shouldRefreshBoardForEvent(event: string) {
export function subscribeToBoardEvents(
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 500 }: { debounceMs?: number } = {},
{ debounceMs = 500, coordinator }: BoardEventOptions = {},
): () => void {
return subscribeToSharedEventSource<EventPayload>({
subscriptions,
return subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
url: queryKeys.system.attach(),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => ({
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? [queryKeys.boards.runs()]
: [],
}),
resyncKeys: () => boardRunKeys(),
resolveInvalidation: boardInvalidation,
fallbackSubscribe: () =>
subscribeToSharedEventSource<EventPayload>({
subscriptions,
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
url: queryKeys.system.attachUrl(),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: boardInvalidation,
}),
});
}
function boardInvalidation(payload: EventPayload) {
return {
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? boardRunKeys()
: [],
};
}
function boardRunKeys() {
return [queryKeys.boards.runs(false), queryKeys.boards.runs(true)];
}
export function useBoardEvents() {
const { mutate } = useSWRConfig();

View file

@ -0,0 +1,736 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
CROSS_TAB_SSE_CHANNEL,
createCrossTabSseCoordinator,
subscribeToCrossTabSse,
type BroadcastChannelLike,
type CrossTabSseCoordinator,
type CrossTabSseMessage,
} from "./cross-tab-sse";
import type { EventPayload, MutateFn } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
type TabVisibility = "visible" | "hidden";
const TEST_TIMING = {
heartbeatMs: 10,
leaderStaleMs: 35,
electionJitterMs: 5,
};
class FakeEventSource {
onmessage: MessageHandler = null;
closed = false;
constructor(
readonly url: string,
readonly owner: string,
) {}
emit(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
close() {
this.closed = true;
}
}
class FakeBroadcastChannel implements BroadcastChannelLike {
static channels = new Set<FakeBroadcastChannel>();
static muted = false;
static throwOnTypes = new Set<CrossTabSseMessage["type"]>();
onmessage: ((event: { data: unknown }) => void) | null = null;
closed = false;
constructor(readonly name: string) {
FakeBroadcastChannel.channels.add(this);
}
postMessage(message: CrossTabSseMessage) {
if (FakeBroadcastChannel.throwOnTypes.has(message.type)) {
throw new Error(`postMessage failed for ${message.type}`);
}
if (FakeBroadcastChannel.muted) return;
const recipients = [...FakeBroadcastChannel.channels].filter(
(channel) => channel !== this && !channel.closed && channel.name === this.name,
);
queueMicrotask(() => {
for (const channel of recipients) {
if (channel.closed) continue;
channel.onmessage?.({ data: { ...message } });
}
});
}
static broadcastExternal(message: CrossTabSseMessage) {
queueMicrotask(() => {
for (const channel of FakeBroadcastChannel.channels) {
if (channel.closed) continue;
channel.onmessage?.({ data: { ...message } });
}
});
}
close() {
this.closed = true;
FakeBroadcastChannel.channels.delete(this);
}
static reset() {
for (const channel of FakeBroadcastChannel.channels) {
channel.closed = true;
}
FakeBroadcastChannel.channels.clear();
FakeBroadcastChannel.muted = false;
FakeBroadcastChannel.throwOnTypes.clear();
}
}
class Harness {
readonly sources: FakeEventSource[] = [];
readonly coordinators = new Map<string, CrossTabSseCoordinator>();
readonly visibility = new Map<string, TabVisibility>();
readonly visibilityHandlers = new Map<string, () => void>();
now = 1000;
createTab(tabId: string, visibility: TabVisibility = "visible") {
this.visibility.set(tabId, visibility);
const coordinator = createCrossTabSseCoordinator({
tabId,
channelFactory: (name) => new FakeBroadcastChannel(name),
eventSourceFactory: (url) => {
const source = new FakeEventSource(url, tabId);
this.sources.push(source);
return source;
},
getVisibility: () => this.visibility.get(tabId) ?? "visible",
addVisibilityChangeListener: (handler) => {
this.visibilityHandlers.set(tabId, handler);
return () => this.visibilityHandlers.delete(tabId);
},
addPagehideListener: () => () => {},
now: () => this.now,
timing: TEST_TIMING,
});
this.coordinators.set(tabId, coordinator);
return coordinator;
}
setVisibility(tabId: string, visibility: TabVisibility) {
this.visibility.set(tabId, visibility);
this.visibilityHandlers.get(tabId)?.();
}
openSources() {
return this.sources.filter((source) => !source.closed);
}
close() {
for (const coordinator of this.coordinators.values()) {
coordinator.close();
}
}
}
const harnesses: Harness[] = [];
afterEach(() => {
for (const harness of harnesses.splice(0)) {
harness.close();
}
FakeBroadcastChannel.reset();
});
describe("subscribeToCrossTabSse", () => {
test("opens one leader-owned global EventSource and keeps followers passive", async () => {
const harness = newHarness();
const cleanups = ["a", "b", "c"].map((tabId) => {
const coordinator = harness.createTab(tabId);
return subscribeForRunEvent(coordinator, []);
});
await waitFor(() => harness.openSources().length === 1);
expect(harness.openSources().map((source) => source.url)).toEqual(["/api/v1/attach"]);
expect([...FakeBroadcastChannel.channels].every((channel) => channel.name === CROSS_TAB_SSE_CHANNEL)).toBe(true);
cleanups.forEach((cleanup) => cleanup());
});
test("leader broadcasts events to all local subscribers", async () => {
const harness = newHarness();
const keysByTab = new Map<string, string[]>();
for (const tabId of ["a", "b", "c"]) {
keysByTab.set(tabId, []);
subscribeForRunEvent(harness.createTab(tabId), keysByTab.get(tabId)!);
}
await waitFor(() => harness.openSources().length === 1);
clearRecordedKeys(keysByTab);
harness.openSources()[0].emit(runEvent({ id: "evt-1", runId: "run-1", seq: 1 }));
await waitFor(() => [...keysByTab.values()].every((keys) => keys.length === 1));
expect(keysByTab.get("a")).toEqual(["event"]);
expect(keysByTab.get("b")).toEqual(["event"]);
expect(keysByTab.get("c")).toEqual(["event"]);
});
test("board and run subscriptions coexist on the same global stream", async () => {
const harness = newHarness();
const coordinator = harness.createTab("a");
const boardKeys: string[] = [];
const runKeys: string[] = [];
subscribeForEvent(coordinator, {
subscriptionKey: "board",
keys: boardKeys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" ? ["board"] : [],
}),
resyncKeys: () => ["board-resync"],
});
subscribeForEvent(coordinator, {
subscriptionKey: "run:run-1",
keys: runKeys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" && payload.run_id === "run-1" ? ["run"] : [],
}),
resyncKeys: () => ["run-resync"],
});
await waitFor(() => harness.openSources().length === 1);
boardKeys.length = 0;
runKeys.length = 0;
harness.openSources()[0].emit(runEvent({ id: "evt-coexist", runId: "run-1", seq: 1 }));
expect(boardKeys).toEqual(["board"]);
expect(runKeys).toEqual(["run"]);
expect(harness.openSources().map((source) => source.url)).toEqual(["/api/v1/attach"]);
});
test("dedupes duplicate event ids until TTL or max-size eviction", async () => {
const harness = newHarness();
const keys: string[] = [];
subscribeForRunEvent(harness.createTab("a"), keys);
await waitFor(() => harness.openSources().length === 1);
keys.length = 0;
const source = harness.openSources()[0];
source.emit(runEvent({ id: "evt-dup", runId: "run-1", seq: 1 }));
source.emit(runEvent({ id: "evt-dup", runId: "run-1", seq: 1 }));
expect(keys).toEqual(["event"]);
harness.now += 5 * 60 * 1000 + 1;
source.emit(runEvent({ id: "evt-dup", runId: "run-1", seq: 1 }));
expect(keys).toEqual(["event", "event"]);
keys.length = 0;
for (let i = 0; i < 1001; i += 1) {
source.emit(runEvent({ id: `evt-${i}`, runId: "run-1", seq: i + 2 }));
}
source.emit(runEvent({ id: "evt-0", runId: "run-1", seq: 2 }));
expect(keys).toHaveLength(1002);
});
test("visible followers take over from a fresh hidden leader and resync", async () => {
const harness = newHarness();
const hiddenKeys: string[] = [];
const visibleKeys: string[] = [];
subscribeForRunEvent(harness.createTab("z", "hidden"), hiddenKeys);
await waitFor(() => harness.openSources().length === 1);
const hiddenSource = harness.openSources()[0];
subscribeForRunEvent(harness.createTab("a", "visible"), visibleKeys);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
expect(hiddenSource.closed).toBe(true);
expect(visibleKeys).toContain("resync");
});
test("visible candidates racing for the same hidden leader resolve lexically", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z", "hidden"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
subscribeForRunEvent(harness.createTab("b", "visible"), []);
subscribeForRunEvent(harness.createTab("a", "visible"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner !== "z");
expect(harness.openSources().map((source) => source.owner)).toEqual(["a"]);
});
test("a lower lexical follower does not preempt a fresh visible leader", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z", "visible"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
subscribeForRunEvent(harness.createTab("a", "visible"), []);
await sleep(TEST_TIMING.electionJitterMs * 4);
expect(harness.openSources().map((source) => source.owner)).toEqual(["z"]);
});
test("stale leader detection opens a new leader source and resyncs followers", async () => {
const harness = newHarness();
const followerKeys: string[] = [];
subscribeForRunEvent(harness.createTab("a"), []);
subscribeForRunEvent(harness.createTab("b"), followerKeys);
await waitFor(() => harness.openSources().length === 1);
const staleLeader = harness.openSources()[0];
harness.coordinators.get(staleLeader.owner)?.close();
harness.now += TEST_TIMING.leaderStaleMs + TEST_TIMING.heartbeatMs + 1;
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner !== staleLeader.owner);
expect(followerKeys).toContain("resync");
});
test("simultaneous stale leader elections resolve to the lexical winner", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
subscribeForRunEvent(harness.createTab("b"), []);
subscribeForRunEvent(harness.createTab("a"), []);
await sleep(TEST_TIMING.heartbeatMs * 2);
harness.coordinators.get("z")?.close();
harness.now += TEST_TIMING.leaderStaleMs + TEST_TIMING.heartbeatMs + 1;
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
});
test("hidden leader ignores candidates for old observed leadership", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z", "hidden"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
const hiddenSource = harness.openSources()[0];
FakeBroadcastChannel.broadcastExternal({
type: "candidate",
version: 1,
tabId: "ghost",
sentAt: harness.now,
candidateId: "ghost",
candidateGeneration: 1,
visibility: "visible",
observedLeaderId: "z",
observedGeneration: 0,
reason: "hidden-leader",
});
await sleep(TEST_TIMING.electionJitterMs * 2);
expect(hiddenSource.closed).toBe(false);
expect(harness.openSources().map((source) => source.owner)).toEqual(["z"]);
});
test("prunes candidate records from older generations", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z", "hidden"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
const coordinator = harness.createTab("a", "visible");
subscribeForRunEvent(coordinator, []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
FakeBroadcastChannel.broadcastExternal({
type: "candidate",
version: 1,
tabId: "old-candidate",
sentAt: harness.now,
candidateId: "old-candidate",
candidateGeneration: 1,
visibility: "visible",
observedLeaderId: "previous-leader",
observedGeneration: 0,
reason: "stale-leader",
});
await sleep(TEST_TIMING.electionJitterMs * 2);
expect(candidateGenerations(coordinator)).not.toContain(1);
});
test("same-generation split brain converges to the higher-priority visible leader", async () => {
const harness = newHarness();
FakeBroadcastChannel.muted = true;
subscribeForRunEvent(harness.createTab("b"), []);
subscribeForRunEvent(harness.createTab("a"), []);
await waitFor(() => harness.openSources().length === 2);
FakeBroadcastChannel.muted = false;
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
});
test("old leader events are ignored after takeover", async () => {
const harness = newHarness();
const keys: string[] = [];
subscribeForRunEvent(harness.createTab("z", "hidden"), []);
await waitFor(() => harness.openSources().length === 1);
const oldSource = harness.openSources()[0];
subscribeForRunEvent(harness.createTab("a", "visible"), keys);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
keys.length = 0;
oldSource.emit(runEvent({ id: "evt-old", runId: "run-1", seq: 1 }));
expect(keys).toEqual([]);
});
test("old leader heartbeats are ignored after takeover", async () => {
const harness = newHarness();
subscribeForRunEvent(harness.createTab("z", "hidden"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "z");
subscribeForRunEvent(harness.createTab("a", "visible"), []);
await waitFor(() => harness.openSources().length === 1 && harness.openSources()[0].owner === "a");
FakeBroadcastChannel.broadcastExternal({
type: "heartbeat",
version: 1,
tabId: "z",
sentAt: harness.now,
leaderId: "z",
generation: 1,
visibility: "hidden",
});
await sleep(TEST_TIMING.heartbeatMs * 2);
expect(harness.openSources().map((source) => source.owner)).toEqual(["a"]);
});
test("last unsubscribe closes the leader source and releases leadership", async () => {
const harness = newHarness();
const cleanup = subscribeForRunEvent(harness.createTab("a"), []);
await waitFor(() => harness.openSources().length === 1);
const source = harness.openSources()[0];
cleanup();
expect(source.closed).toBe(true);
expect(harness.openSources()).toEqual([]);
});
test("missing BroadcastChannel uses subscriber fallback", () => {
const coordinator = createCrossTabSseCoordinator({
channelFactory: () => {
throw new Error("no channel");
},
});
let fallbackStarted = 0;
let fallbackStopped = 0;
const cleanup = subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey: "fallback",
mutate: (() => Promise.resolve()) as MutateFn,
resolveInvalidation: () => ({ keys: [] }),
resyncKeys: () => [],
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {
fallbackStopped += 1;
};
},
debounceMs: 0,
});
cleanup();
expect(fallbackStarted).toBe(1);
expect(fallbackStopped).toBe(1);
});
test("postMessage failure after initialization degrades to fallback without coordinated resync", async () => {
const harness = newHarness();
const coordinator = harness.createTab("a");
const keys: string[] = [];
let fallbackStarted = 0;
let fallbackStopped = 0;
FakeBroadcastChannel.throwOnTypes.add("leader-changed");
const cleanup = subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey: "throwing-channel",
mutate: ((key: string) => {
keys.push(key);
return Promise.resolve();
}) as MutateFn,
resolveInvalidation: () => ({ keys: ["event"] }),
resyncKeys: () => ["resync"],
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {
fallbackStopped += 1;
};
},
debounceMs: 0,
});
await waitFor(() => fallbackStarted === 1);
expect(harness.openSources()).toEqual([]);
expect(keys).toEqual([]);
cleanup();
expect(fallbackStopped).toBe(1);
});
test("close resets coordination availability after an initial channel failure", async () => {
let channelUnavailable = true;
const sources: FakeEventSource[] = [];
const coordinator = createCrossTabSseCoordinator({
tabId: "recovering",
channelFactory: (name) => {
if (channelUnavailable) throw new Error("channel unavailable");
return new FakeBroadcastChannel(name);
},
eventSourceFactory: (url) => {
const source = new FakeEventSource(url, "recovering");
sources.push(source);
return source;
},
addVisibilityChangeListener: () => () => {},
addPagehideListener: () => () => {},
timing: TEST_TIMING,
});
let firstFallbackStarted = 0;
let secondFallbackStarted = 0;
const firstCleanup = subscribeWithFallback(coordinator, {
fallbackSubscribe: () => {
firstFallbackStarted += 1;
return () => {};
},
});
firstCleanup();
coordinator.close();
channelUnavailable = false;
const secondCleanup = subscribeWithFallback(coordinator, {
fallbackSubscribe: () => {
secondFallbackStarted += 1;
return () => {};
},
});
await waitFor(() => sources.some((source) => !source.closed));
expect(firstFallbackStarted).toBe(1);
expect(secondFallbackStarted).toBe(0);
expect(sources.filter((source) => !source.closed).map((source) => source.url)).toEqual(["/api/v1/attach"]);
secondCleanup();
coordinator.close();
});
test("last unsubscribe retries coordination after an initial channel failure", async () => {
let channelUnavailable = true;
const sources: FakeEventSource[] = [];
const coordinator = createCrossTabSseCoordinator({
tabId: "retry-after-unsubscribe",
channelFactory: (name) => {
if (channelUnavailable) throw new Error("channel unavailable");
return new FakeBroadcastChannel(name);
},
eventSourceFactory: (url) => {
const source = new FakeEventSource(url, "retry-after-unsubscribe");
sources.push(source);
return source;
},
addVisibilityChangeListener: () => () => {},
addPagehideListener: () => () => {},
timing: TEST_TIMING,
});
let fallbackStarted = 0;
const firstCleanup = subscribeWithFallback(coordinator, {
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {};
},
});
firstCleanup();
channelUnavailable = false;
const secondCleanup = subscribeWithFallback(coordinator, {
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {};
},
});
await waitFor(() => sources.some((source) => !source.closed));
expect(fallbackStarted).toBe(1);
expect(sources.filter((source) => !source.closed).map((source) => source.url)).toEqual(["/api/v1/attach"]);
secondCleanup();
coordinator.close();
});
test("close stops fallback subscriptions added after degradation", async () => {
const harness = newHarness();
const coordinator = harness.createTab("a");
let fallbackStarted = 0;
let fallbackStopped = 0;
FakeBroadcastChannel.throwOnTypes.add("leader-changed");
subscribeWithFallback(coordinator, {
subscriptionKey: "before-degrade",
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {
fallbackStopped += 1;
};
},
});
await waitFor(() => fallbackStarted === 1);
FakeBroadcastChannel.throwOnTypes.clear();
subscribeWithFallback(coordinator, {
subscriptionKey: "after-degrade",
fallbackSubscribe: () => {
fallbackStarted += 1;
return () => {
fallbackStopped += 1;
};
},
});
expect(fallbackStarted).toBe(2);
coordinator.close();
expect(fallbackStopped).toBe(2);
});
});
function newHarness() {
const harness = new Harness();
harnesses.push(harness);
return harness;
}
function subscribeForRunEvent(coordinator: CrossTabSseCoordinator, keys: string[]) {
return subscribeForEvent(coordinator, {
subscriptionKey: "run-feed",
keys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" ? ["event"] : [],
}),
resyncKeys: () => ["resync"],
});
}
function subscribeForEvent(
coordinator: CrossTabSseCoordinator,
{
subscriptionKey,
keys,
resolveInvalidation,
resyncKeys,
}: {
subscriptionKey: string;
keys: string[];
resolveInvalidation: (payload: EventPayload) => { keys: string[] };
resyncKeys: () => string[];
},
) {
return subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey,
mutate: ((key: string) => {
keys.push(key);
return Promise.resolve();
}) as MutateFn,
resolveInvalidation,
resyncKeys,
fallbackSubscribe: () => {
throw new Error("fallback should not be used");
},
debounceMs: 0,
});
}
function subscribeWithFallback(
coordinator: CrossTabSseCoordinator,
{
subscriptionKey = "fallback-test",
fallbackSubscribe,
}: {
subscriptionKey?: string;
fallbackSubscribe: () => () => void;
},
) {
return subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey,
mutate: (() => Promise.resolve()) as MutateFn,
resolveInvalidation: () => ({ keys: [] }),
resyncKeys: () => [],
fallbackSubscribe,
debounceMs: 0,
});
}
function candidateGenerations(coordinator: CrossTabSseCoordinator): number[] {
const inspectable = coordinator as unknown as {
candidates: Map<string, { candidateGeneration: number }>;
};
return [...inspectable.candidates.values()].map((candidate) => candidate.candidateGeneration);
}
function runEvent({
id,
runId,
seq,
}: {
id: string;
runId: string;
seq: number;
}) {
return {
id,
seq,
run_id: runId,
event: "run.running",
ts: "2026-05-04T12:00:00.000Z",
};
}
async function waitFor(condition: () => boolean, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await sleep(2);
}
throw new Error("condition did not become true before timeout");
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function clearRecordedKeys(keysByTab: Map<string, string[]>) {
for (const keys of keysByTab.values()) {
keys.length = 0;
}
}

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,42 @@ export function formatBytes(bytes: number): string {
return `${bytes} B`;
}
/**
* Short relative time string (e.g. "just now", "15s ago", "4m ago", "2h ago", "3d ago").
* Future timestamps clamp to "just now".
*/
export function formatRelativeTime(iso: string, now: number = Date.now()): string {
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const secs = Math.max(0, Math.floor((now - then) / 1000));
if (secs < 5) return "just now";
if (secs < 60) return `${secs}s ago`;
const minutes = Math.floor(secs / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* Format an ISO 8601 timestamp as an absolute, human-readable datetime
* (e.g., "04/24/2026, 1:23:40 PM"). Falls back to the input if unparseable.
*/
export function formatAbsoluteTs(iso: string): string {
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return iso;
return new Date(ms).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
}
/**
* Format seconds into a duration string for display (e.g., "1m 12s", "23s").
*/
@ -43,3 +79,17 @@ export function formatDurationSecs(secs: number): string {
const remainMin = minutes % 60;
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
}
export function formatDurationMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
export function formatTokenCount(value: number): string {
if (value < 1000) return `${value}`;
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
return `${Math.round(value / 1_000_000)}M`;
}

View file

@ -0,0 +1,131 @@
import { describe, expect, test } from "bun:test";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
} from "./cross-tab-sse";
import { subscribeToLiveEvents, type LiveEventPayload } from "./live-events";
import type { EventSourceLike } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
class FakeEventSource {
onmessage: MessageHandler = null;
closed = false;
emit(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
close() {
this.closed = true;
}
}
class FakeBroadcastChannel implements BroadcastChannelLike {
onmessage: ((event: { data: unknown }) => void) | null = null;
postMessage() {}
close() {}
}
describe("subscribeToLiveEvents", () => {
test("coordinated mode opens /api/v1/attach and forwards every payload", async () => {
const source = new FakeEventSource();
const created: string[] = [];
const seen: LiveEventPayload[] = [];
const coordinator = createCoordinator((url) => {
created.push(url);
return source;
});
const cleanup = subscribeToLiveEvents(
(payload) => seen.push(payload),
() => {
throw new Error("source should be created by coordinator");
},
{ coordinator },
);
await waitFor(() => created.length === 1);
source.emit({ id: "evt-1", event: "stage.started", run_id: "run-a" });
source.emit({ id: "evt-2", event: "agent.message", run_id: "run-b" });
expect(created).toEqual(["/api/v1/attach"]);
expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]);
cleanup();
coordinator.close();
});
test("fallback mode opens /api/v1/attach (not a per-run URL) and forwards payloads", () => {
const source = new FakeEventSource();
const created: string[] = [];
const seen: LiveEventPayload[] = [];
const coordinator = createFallbackCoordinator();
const cleanup = subscribeToLiveEvents(
(payload) => seen.push(payload),
(url) => {
created.push(url);
return source;
},
{ coordinator },
);
source.emit({ id: "evt-1", event: "run.completed", run_id: "run-a" });
source.emit({ id: "evt-2", event: "run.failed", run_id: "run-b" });
expect(created).toEqual(["/api/v1/attach"]);
expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]);
cleanup();
coordinator.close();
});
test("fallback closes the shared source on the final unsubscribe", () => {
const source = new FakeEventSource();
const coordinator = createFallbackCoordinator();
const cleanup = subscribeToLiveEvents(() => {}, () => source, { coordinator });
expect(source.closed).toBe(false);
cleanup();
expect(source.closed).toBe(true);
coordinator.close();
});
});
function createCoordinator(eventSourceFactory: (url: string) => EventSourceLike) {
return createCrossTabSseCoordinator({
tabId: "live-events-test",
channelFactory: () => new FakeBroadcastChannel(),
eventSourceFactory,
addVisibilityChangeListener: () => () => {},
addPagehideListener: () => () => {},
timing: {
heartbeatMs: 10,
leaderStaleMs: 50,
electionJitterMs: 0,
},
});
}
function createFallbackCoordinator() {
return createCrossTabSseCoordinator({
channelFactory: () => {
throw new Error("BroadcastChannel unavailable");
},
});
}
async function waitFor(condition: () => boolean, timeoutMs = 200) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error("condition did not become true before timeout");
}

View file

@ -0,0 +1,64 @@
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
type EventPayload,
type EventSourceLike,
type SharedEventSubscription,
type SseKey,
} from "./sse";
export interface LiveEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
ts?: string;
run_id?: string;
node_id?: string;
stage_id?: string;
properties?: Record<string, unknown>;
}
interface LiveEventOptions {
coordinator?: CrossTabSseCoordinator;
}
const subscriptions = new Map<string, SharedEventSubscription>();
const SUBSCRIPTION_KEY = "live-events";
const NO_KEYS: SseKey[] = [];
const NOOP_MUTATE = () => Promise.resolve();
export function subscribeToLiveEvents(
onEvent: (payload: LiveEventPayload) => void,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ coordinator }: LiveEventOptions = {},
): () => void {
return subscribeToCrossTabSse<LiveEventPayload>({
coordinator,
subscriptionKey: SUBSCRIPTION_KEY,
mutate: NOOP_MUTATE,
debounceMs: 0,
resyncKeys: () => NO_KEYS,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<LiveEventPayload>({
subscriptions,
subscriptionKey: SUBSCRIPTION_KEY,
url: queryKeys.system.attachUrl(),
mutate: NOOP_MUTATE,
eventSourceFactory,
debounceMs: 0,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
}),
});
}

View file

@ -2,11 +2,18 @@ import useSWRMutation from "swr/mutation";
import { useSWRConfig } from "swr";
import type {
PreviewUrlResponse,
RunStatusResponse,
Run,
SteerRunRequest,
SubmitAnswerRequest,
UpdateRunRequest,
} from "@qltysh/fabro-api-client";
import { apiJsonMutation } from "./api-client";
import {
apiData,
authApi,
humanInTheLoopApi,
runsApi,
} from "./api-client";
import { queryKeys } from "./query-keys";
import type { LifecycleAction, LifecycleActionError } from "./run-actions";
import {
@ -19,6 +26,7 @@ import {
export type PreviewRunArg = {
port: number;
expires_in_secs: number;
signed?: boolean;
};
export type PreviewMutationResult = {
@ -30,7 +38,7 @@ export type LifecycleMutationResult =
| {
intent: LifecycleAction;
ok: true;
run: RunStatusResponse;
run: Run;
}
| {
intent: LifecycleAction;
@ -41,8 +49,10 @@ export type LifecycleMutationResult =
export function usePreviewRun(id: string | undefined) {
return useSWRMutation(
id ? queryKeys.runs.preview(id) : null,
async (key: string, { arg }: { arg: PreviewRunArg }): Promise<PreviewMutationResult> => {
const result = await apiJsonMutation<PreviewUrlResponse, PreviewRunArg>(key, { arg });
async (_key, { arg }: { arg: PreviewRunArg }): Promise<PreviewMutationResult> => {
const result = await apiData<PreviewUrlResponse>(() =>
humanInTheLoopApi.generatePreviewUrl(id!, arg),
);
return { intent: "preview", url: result.url };
},
);
@ -63,7 +73,7 @@ export function useUnarchiveRun(id: string | undefined) {
function useLifecycleMutation(
id: string | undefined,
intent: LifecycleAction,
action: (id: string) => Promise<RunStatusResponse>,
action: (id: string) => Promise<Run>,
) {
const { mutate } = useSWRConfig();
const key = id ? queryKeys.runs[intent](id) : null;
@ -94,7 +104,29 @@ function useLifecycleMutation(
);
}
export type SubmitInterviewAnswerArg = SubmitAnswerRequest & { questionId: string };
export function useUpdateRunTitle(id: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
id ? queryKeys.runs.updateTitle(id) : null,
async (_key, { arg }: { arg: UpdateRunRequest }): Promise<Run> => {
if (!id) throw new Error("id is required");
return apiData(() => runsApi.updateRun(id, arg));
},
{
onSuccess: (run) => {
if (!id) return;
void mutate(queryKeys.runs.detail(id), run, { revalidate: false });
void mutate(queryKeys.boards.runs());
void mutate(queryKeys.boards.runs(true));
},
},
);
}
export type SubmitInterviewAnswerArg = {
questionId: string;
answer: SubmitAnswerRequest;
};
export function useSubmitInterviewAnswer(runId: string | undefined) {
const { mutate } = useSWRConfig();
@ -102,9 +134,9 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
runId ? `interview-answer:${runId}` : null,
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
if (!runId) throw new Error("runId is required");
const { questionId, ...body } = arg;
const path = `/api/v1/runs/${encodeURIComponent(runId)}/questions/${encodeURIComponent(questionId)}/answer`;
await apiJsonMutation<void, SubmitAnswerRequest>(path, { arg: body });
await apiData(() =>
humanInTheLoopApi.submitRunAnswer(runId, arg.questionId, arg.answer),
);
},
{
onSuccess: () => {
@ -116,12 +148,48 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
);
}
export type SteerRunArg = SteerRunRequest;
export function useInterruptRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `interrupt-run:${runId}` : null,
async (_key: string) => {
if (!runId) throw new Error("runId is required");
await apiData(() => humanInTheLoopApi.interruptRun(runId));
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useSteerRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `steer-run:${runId}` : null,
async (_key: string, { arg }: { arg: SteerRunArg }) => {
if (!runId) throw new Error("runId is required");
await apiData(() => humanInTheLoopApi.steerRun(runId, arg));
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useToggleDemoMode() {
const { mutate } = useSWRConfig();
return useSWRMutation(
queryKeys.demo.toggle(),
async (key: string, { arg }: { arg: { enabled: boolean } }) => {
await apiJsonMutation<void, { enabled: boolean }>(key, { arg });
async (_key, { arg }: { arg: { enabled: boolean } }) => {
await apiData(() => authApi.toggleDemo(arg));
},
{
onSuccess: () => {
@ -133,18 +201,11 @@ export function useToggleDemoMode() {
export function useLoginDevToken() {
return useSWRMutation(
"/auth/login/dev-token",
async (key: string, { arg }: { arg: { token: string } }) => {
const response = await fetch(key, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
queryKeys.auth.loginDevToken(),
async (_key, { arg }: { arg: { token: string } }) => {
return apiData(() => authApi.loginDevToken(arg), {
redirectOnUnauthorized: false,
});
if (!response.ok) {
throw new Error(response.statusText || `HTTP ${response.status}`);
}
return response.json() as Promise<{ ok: boolean }>;
},
);
}

View file

@ -0,0 +1,6 @@
export function workerFactory(): Worker {
return new Worker(
"/assets/pierre-diffs-worker/worker-portable.js",
{ type: "module" },
);
}

View file

@ -1,32 +1,55 @@
import useSWR, { type SWRConfiguration } from "swr";
import type {
ApiQuestion,
AuthConfigResponse,
AuthMeResponse,
AuthSessionsResponse,
CommandLogResponse,
EventEnvelope,
PaginatedBoardRunList,
PaginatedEventList,
PaginatedRunCommitList,
PaginatedRunFileList,
PaginatedRunList,
PaginatedRunStageList,
PaginatedStageTurnList,
CommandLogResponse,
CommandOutputStream,
PaginatedWorkflowListResponse,
RunArtifactListResponse,
RunBilling,
RunProjection,
Run,
SandboxDetails,
SandboxFileListResponse,
SandboxServiceListResponse,
ServerSettings,
RunSummary,
SystemInfoResponse,
VncPreviewResponse,
WorkflowDetailResponse,
WorkflowSettings,
} from "@qltysh/fabro-api-client";
import type { PaginatedWorkflowListResponse, WorkflowDetailResponse } from "./workflow-api";
import {
apiFetcher,
apiNullableFetcher,
apiNullableTextFetcher,
apiPaginatedFetcher,
apiTextFetcher,
apiData,
apiNullableData,
apiResponse,
authApi,
fetchAllPages,
fetchAllStageEvents,
generatedAxios,
humanInTheLoopApi,
insightsApi,
runInternalsApi,
runOutputsApi,
runsApi,
settingsApi,
systemApi,
workflowsApi,
type PaginatedEnvelope,
} from "./api-client";
import { queryKeys } from "./query-keys";
import {
queryKeys,
runFileScopeSelection,
type RunFileSelection,
type RunGraphDirection,
} from "./query-keys";
const immutableOptions: SWRConfiguration = {
revalidateIfStale: false,
@ -34,58 +57,96 @@ const immutableOptions: SWRConfiguration = {
revalidateOnReconnect: false,
};
type BoardRunsEnvelope = PaginatedEnvelope<PaginatedBoardRunList["data"][number]> &
Pick<PaginatedBoardRunList, "columns">;
export function useAuthConfig() {
return useSWR<{ methods: string[] }>(queryKeys.auth.config(), apiFetcher, immutableOptions);
return useSWR<AuthConfigResponse>(
queryKeys.auth.config(),
() => apiData(() => authApi.getAuthConfig()),
immutableOptions,
);
}
export function useAuthMe() {
return useSWR<{
user: {
login: string;
name: string;
email: string;
avatarUrl: string;
userUrl: string;
};
provider: string;
demoMode: boolean;
}>(queryKeys.auth.me(), apiFetcher, { dedupingInterval: 10_000 });
return useSWR<AuthMeResponse>(
queryKeys.auth.me(),
() => apiData(() => authApi.getAuthMe()),
{ dedupingInterval: 10_000 },
);
}
export function useAuthSessions() {
return useSWR<AuthSessionsResponse>(
queryKeys.auth.sessions(),
() => apiData(() => authApi.listAuthSessions()),
);
}
export function useSystemInfo() {
return useSWR<SystemInfoResponse>(
queryKeys.system.info(),
apiFetcher,
() => apiData(() => systemApi.getSystemInfo()),
immutableOptions,
);
}
export function useBoardsRuns() {
return useSWR<
PaginatedEnvelope<PaginatedBoardRunList["data"][number]> & {
columns: { id: string; name: string }[];
}
>(queryKeys.boards.runs(), apiPaginatedFetcher);
export function useBoardsRuns(includeArchived: boolean = false) {
return useSWR<BoardRunsEnvelope>(
queryKeys.boards.runs(includeArchived),
() =>
fetchAllPages("board runs", (limit, offset) =>
apiData(() => runsApi.listBoardRuns(limit, offset, includeArchived)),
),
);
}
export function useRun(id: string | undefined) {
return useSWR<RunSummary | null>(
return useSWR<Run | null>(
id ? queryKeys.runs.detail(id) : null,
apiNullableFetcher,
() => apiNullableData(() => runsApi.retrieveRun(id!)),
);
}
export function useRunState(id: string | undefined) {
return useSWR<RunProjection | null>(
id ? queryKeys.runs.state(id) : null,
apiNullableFetcher,
() => apiNullableData(() => runInternalsApi.getRunState(id!)),
);
}
export function useRunFiles(id: string | undefined) {
export function useRunFiles(
id: string | undefined,
selection: RunFileSelection = runFileScopeSelection("committed"),
) {
return useSWR<PaginatedRunFileList | null>(
id ? queryKeys.runs.files(id) : null,
apiNullableFetcher,
id ? queryKeys.runs.files(id, selection) : null,
() =>
apiNullableData(() =>
selection.kind === "scope"
? runOutputsApi.listRunFiles(
id!,
undefined,
undefined,
selection.scope,
)
: runOutputsApi.listRunFiles(
id!,
undefined,
undefined,
undefined,
selection.fromSha,
selection.toSha,
),
),
{ keepPreviousData: true },
);
}
export function useRunCommits(id: string | undefined) {
return useSWR<PaginatedRunCommitList | null>(
id ? queryKeys.runs.commits(id) : null,
() => apiNullableData(() => runOutputsApi.listRunCommits(id!, 100)),
{ keepPreviousData: true },
);
}
@ -93,88 +154,165 @@ export function useRunFiles(id: string | undefined) {
export function useRunStages(id: string | undefined) {
return useSWR<PaginatedRunStageList | null>(
id ? queryKeys.runs.stages(id) : null,
apiNullableFetcher,
() => apiNullableData(() => runInternalsApi.listRunStages(id!)),
);
}
export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
export function useRunGraph(id: string | undefined, direction?: RunGraphDirection) {
return useSWR<string | null>(
id ? queryKeys.runs.graph(id, direction) : null,
apiNullableTextFetcher,
() => apiNullableData(() => runsApi.retrieveRunGraph(id!, direction)),
);
}
export function useRunGraphSource(id: string | undefined, enabled: boolean) {
return useSWR<string | null>(
id && enabled ? queryKeys.runs.graphSource(id) : null,
apiNullableTextFetcher,
() => apiNullableData(() => runsApi.retrieveRunGraphSource(id!)),
);
}
export function useRunLogs(id: string | undefined, refreshInterval?: number) {
return useSWR<string | null>(
id ? queryKeys.runs.logs(id) : null,
apiNullableTextFetcher,
() => apiNullableData(() => runInternalsApi.getRunLogs(id!)),
refreshInterval ? { refreshInterval } : undefined,
);
}
export function useRunArtifacts(id: string | undefined) {
return useSWR<RunArtifactListResponse | null>(
id ? queryKeys.runs.artifacts(id) : null,
() => apiNullableData(() => runInternalsApi.listRunArtifacts(id!)),
);
}
export function useRunSettings<T = WorkflowSettings>(id: string | undefined) {
return useSWR<T>(
id ? queryKeys.runs.settings(id) : null,
apiFetcher,
() => apiData(() => runInternalsApi.retrieveRunSettings(id!)) as Promise<T>,
immutableOptions,
);
}
export function useRunBilling(id: string | undefined) {
return useSWR<RunBilling>(id ? queryKeys.runs.billing(id) : null, apiFetcher);
return useSWR<RunBilling>(
id ? queryKeys.runs.billing(id) : null,
() => apiData(() => runOutputsApi.retrieveRunBilling(id!)),
);
}
export function useRunSandboxDetails(id: string | undefined) {
return useSWR<SandboxDetails | null>(
id ? queryKeys.runs.sandbox(id) : null,
() => apiNullableData(() => humanInTheLoopApi.retrieveRunSandbox(id!)),
);
}
export function useSandboxFiles(
id: string | undefined,
path: string | undefined,
depth?: number,
) {
return useSWR<SandboxFileListResponse>(
id && path ? queryKeys.runs.sandboxFiles(id, path, depth) : null,
() => apiData(() => humanInTheLoopApi.listSandboxFiles(id!, path!, depth)),
{ keepPreviousData: true },
);
}
export function useSandboxServices(id: string | undefined) {
return useSWR<SandboxServiceListResponse>(
id ? queryKeys.runs.sandboxServices(id) : null,
() => apiData(() => humanInTheLoopApi.listSandboxServices(id!)),
{ keepPreviousData: true },
);
}
export function useSandboxVncPreview(id: string | undefined, enabled: boolean) {
return useSWR<VncPreviewResponse>(
id && enabled ? queryKeys.runs.sandboxVnc(id) : null,
() => apiData(() => humanInTheLoopApi.createSandboxVncPreview(id!)),
{ revalidateOnFocus: false, revalidateOnReconnect: false, shouldRetryOnError: false },
);
}
export function useSandboxFile(
id: string | undefined,
path: string | null | undefined,
) {
return useSWR<ArrayBuffer>(
id && path ? queryKeys.runs.sandboxFile(id, path) : null,
async () => {
const url = `/api/v1/runs/${encodeURIComponent(id!)}/sandbox/file`;
const response = await apiResponse(() =>
generatedAxios.get<ArrayBuffer>(url, {
params: { path: path! },
responseType: "arraybuffer",
}),
);
return response.data;
},
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);
}
export function useRunQuestions(id: string | undefined, enabled: boolean) {
return useSWR<ApiQuestion[]>(
id && enabled ? queryKeys.runs.questions(id, 25, 0) : null,
async (key) => {
const payload = await apiNullableFetcher<{ data: ApiQuestion[] }>(key);
async () => {
const payload = await apiNullableData(() => humanInTheLoopApi.listRunQuestions(id!, 25, 0));
return payload?.data ?? [];
},
);
}
export function useRunStageTurns(
id: string | undefined,
stageId: string | undefined,
enabled = true,
) {
return useSWR<PaginatedStageTurnList | null>(
id && stageId && enabled ? queryKeys.runs.stageTurns(id, stageId) : null,
apiNullableFetcher,
export function useRunStageEvents(id: string | undefined, stageId: string | undefined) {
return useSWR<EventEnvelope[]>(
id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null,
() =>
fetchAllStageEvents(`run ${id} stage ${stageId}`, (sinceSeq, limit) =>
apiData(() => runInternalsApi.listStageEvents(id!, stageId!, sinceSeq, limit)),
),
);
}
export function useRunEventsList(id: string | undefined, enabled = true) {
return useSWR<PaginatedEventList | null>(
id && enabled ? queryKeys.runs.events(id, 1000) : null,
apiNullableFetcher,
export function useRunEventsList(id: string | undefined) {
return useSWR<EventEnvelope[]>(
id ? queryKeys.runs.events(id, 1000) : null,
() =>
fetchAllStageEvents(`run ${id} events`, (sinceSeq, limit) =>
apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)),
),
);
}
export function fetchRunCommandLog(
id: string,
stageId: string,
stream: CommandOutputStream,
offset: number,
limit?: number,
) {
return apiFetcher<CommandLogResponse>(
queryKeys.runs.stageLog(id, stageId, stream, offset, limit),
return apiData<CommandLogResponse>(() =>
runInternalsApi.getRunStageCommandLog(id, stageId, offset, limit),
);
}
export function useRunStageLog(
id: string | undefined,
stageId: string | undefined,
enabled: boolean,
) {
return useSWR<CommandLogResponse>(
enabled && id && stageId ? queryKeys.runs.stageLog(id, stageId) : null,
() => apiData(() => runInternalsApi.getRunStageCommandLog(id!, stageId!)),
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),
apiNullableFetcher,
() => apiNullableData(() => workflowsApi.listWorkflows()),
immutableOptions,
);
}
@ -182,7 +320,7 @@ export function useWorkflows() {
export function useWorkflow(name: string | undefined) {
return useSWR<WorkflowDetailResponse | null>(
name ? queryKeys.workflows.detail(name) : null,
apiNullableFetcher,
() => apiNullableData(() => workflowsApi.retrieveWorkflow(name!)),
immutableOptions,
);
}
@ -190,20 +328,30 @@ export function useWorkflow(name: string | undefined) {
export function useWorkflowRuns(name: string | undefined) {
return useSWR<PaginatedRunList | null>(
name ? queryKeys.workflows.runs(name) : null,
apiNullableFetcher,
() => apiNullableData(() => workflowsApi.listWorkflowRuns(name!)),
);
}
export function useInsightsQueries() {
return useSWR(queryKeys.insights.queries(), apiFetcher, immutableOptions);
return useSWR(
queryKeys.insights.queries(),
() => apiData(() => insightsApi.listSavedQueries()),
immutableOptions,
);
}
export function useInsightsHistory() {
return useSWR(queryKeys.insights.history(), apiFetcher, immutableOptions);
return useSWR(
queryKeys.insights.history(),
() => apiData(() => insightsApi.listQueryHistory()),
immutableOptions,
);
}
export function useServerSettings() {
return useSWR<ServerSettings>(queryKeys.settings.server(), apiFetcher, immutableOptions);
return useSWR<ServerSettings>(
queryKeys.settings.server(),
() => apiData(() => settingsApi.retrieveServerSettings()),
immutableOptions,
);
}
export { apiTextFetcher };

View file

@ -4,26 +4,87 @@ import { queryKeys } from "./query-keys";
import { queryKeysForRunEvent } from "./run-events";
describe("queryKeys", () => {
test("uses API path strings as stable SWR keys", () => {
expect(queryKeys.auth.me()).toBe("/api/v1/auth/me");
expect(queryKeys.runs.files("run 1")).toBe("/api/v1/runs/run%201/files");
expect(queryKeys.runs.graph("run-1", "TB")).toBe("/api/v1/runs/run-1/graph?direction=TB");
expect(queryKeys.runs.stageLog("run 1", "build step@2", "stderr", 12, 34)).toBe(
"/api/v1/runs/run%201/stages/build%20step%402/logs/stderr?offset=12&limit=34",
);
test("uses semantic tuples as stable SWR keys and keeps SSE URLs explicit", () => {
expect(queryKeys.auth.me()).toEqual(["auth", "me"]);
expect(queryKeys.runs.files("run 1")).toEqual([
"runs",
"files",
"run 1",
"scope",
"committed",
]);
expect(queryKeys.runs.files("run 1", { kind: "scope", scope: "all" })).toEqual([
"runs",
"files",
"run 1",
"scope",
"all",
]);
expect(
queryKeys.runs.files("run 1", {
kind: "commit",
fromSha: "abc1234",
toSha: "def5678",
}),
).toEqual(["runs", "files", "run 1", "commit", "abc1234", "def5678"]);
expect(queryKeys.runs.commits("run 1")).toEqual(["runs", "commits", "run 1"]);
expect(queryKeys.runs.graph("run-1", "TB")).toEqual(["runs", "graph", "run-1", "TB"]);
expect(queryKeys.runs.stageLog("run 1", "build step@2", 12, 34)).toEqual([
"runs",
"stage-log",
"run 1",
"build step@2",
12,
34,
]);
expect(queryKeys.runs.stageEvents("run 1", "build step")).toEqual([
"runs",
"stage-events",
"run 1",
"build step",
]);
expect(queryKeys.runs.sandbox("run 1")).toEqual(["runs", "sandbox", "run 1"]);
expect(queryKeys.system.attachUrl()).toBe("/api/v1/attach");
expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach");
});
test("event-mapped keys match query hook resources", () => {
expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual([
queryKeys.runs.files("run-1"),
]);
expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual(
[
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
],
);
expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.detail("run-1"),
queryKeys.runs.stageTurns("run-1", "stage-1"),
queryKeys.runs.stageEvents("run-1", "stage-1"),
]);
expect(queryKeysForRunEvent("run-1", "run.title.updated")).toEqual([
queryKeys.runs.detail("run-1"),
]);
});
test("agent activity events invalidate the per-stage events key", () => {
for (const event of [
"stage.prompt",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"command.started",
"command.completed",
]) {
expect(queryKeysForRunEvent("run-1", event, "stage-1")).toEqual([
queryKeys.runs.stageEvents("run-1", "stage-1"),
]);
}
});
test("agent activity events without a node_id invalidate nothing", () => {
expect(queryKeysForRunEvent("run-1", "agent.message")).toEqual([]);
});
});

View file

@ -1,78 +1,94 @@
export type RunGraphDirection = "LR" | "TB" | "BT" | "RL";
export type RunFileScope = "committed" | "uncommitted" | "all";
export type RunFileSelection =
| { kind: "scope"; scope: RunFileScope }
| { kind: "commit"; fromSha: string; toSha: string };
export type QueryKey = readonly unknown[];
export const RUN_FILE_SCOPES = ["committed", "uncommitted", "all"] as const;
export function runFileScopeSelection(
scope: RunFileScope = "committed",
): RunFileSelection {
return { kind: "scope", scope };
}
function pathSegment(value: string): string {
return encodeURIComponent(value);
}
function withQuery(path: string, params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null) search.set(key, String(value));
}
const query = search.toString();
return query ? `${path}?${query}` : path;
function fileSelectionKey(selection: RunFileSelection): readonly unknown[] {
return selection.kind === "scope"
? ["scope", selection.scope]
: ["commit", selection.fromSha, selection.toSha];
}
export const queryKeys = {
auth: {
config: () => "/api/v1/auth/config",
me: () => "/api/v1/auth/me",
config: () => ["auth", "config"] as const,
me: () => ["auth", "me"] as const,
sessions: () => ["auth", "sessions"] as const,
loginDevToken: () => ["auth", "login-dev-token"] as const,
},
demo: {
toggle: () => "/api/v1/demo/toggle",
toggle: () => ["demo", "toggle"] as const,
},
system: {
info: () => "/api/v1/system/info",
attach: () => "/api/v1/attach",
info: () => ["system", "info"] as const,
attachUrl: () => "/api/v1/attach",
},
boards: {
runs: () => "/api/v1/boards/runs",
runs: (includeArchived = false) => ["boards", "runs", includeArchived] as const,
},
runs: {
detail: (id: string) => `/api/v1/runs/${pathSegment(id)}`,
state: (id: string) => `/api/v1/runs/${pathSegment(id)}/state`,
files: (id: string) => `/api/v1/runs/${pathSegment(id)}/files`,
stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`,
graph: (id: string, direction?: "LR" | "TB") =>
withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }),
graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`,
settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`,
logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`,
billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`,
questions: (id: string, limit = 1, offset = 0) =>
withQuery(`/api/v1/runs/${pathSegment(id)}/questions`, {
"page[limit]": limit,
"page[offset]": offset,
}),
events: (id: string, limit = 1000) =>
withQuery(`/api/v1/runs/${pathSegment(id)}/events`, { limit }),
stageTurns: (id: string, stageId: string) =>
`/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/turns`,
stageLog: (
id: string,
stageId: string,
stream: "stdout" | "stderr",
offset = 0,
limit = 65_536,
) =>
withQuery(
`/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/logs/${stream}`,
{ offset, limit },
detail: (id: string) => ["runs", "detail", id] as const,
state: (id: string) => ["runs", "state", id] as const,
files: (id: string, selection: RunFileSelection = runFileScopeSelection()) =>
["runs", "files", id, ...fileSelectionKey(selection)] as const,
filesAllScopes: (id: string) =>
RUN_FILE_SCOPES.map((scope) =>
queryKeys.runs.files(id, runFileScopeSelection(scope)),
),
preview: (id: string) => `/api/v1/runs/${pathSegment(id)}/preview`,
cancel: (id: string) => `/api/v1/runs/${pathSegment(id)}/cancel`,
archive: (id: string) => `/api/v1/runs/${pathSegment(id)}/archive`,
unarchive: (id: string) => `/api/v1/runs/${pathSegment(id)}/unarchive`,
attach: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
commits: (id: string) => ["runs", "commits", id] as const,
stages: (id: string) => ["runs", "stages", id] as const,
graph: (id: string, direction?: RunGraphDirection) =>
["runs", "graph", id, direction ?? null] as const,
graphSource: (id: string) => ["runs", "graph-source", id] as const,
settings: (id: string) => ["runs", "settings", id] as const,
logs: (id: string) => ["runs", "logs", id] as const,
artifacts: (id: string) => ["runs", "artifacts", id] as const,
billing: (id: string) => ["runs", "billing", id] as const,
questions: (id: string, limit = 1, offset = 0) =>
["runs", "questions", id, limit, offset] as const,
events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const,
stageEvents: (id: string, stageId: string) =>
["runs", "stage-events", id, stageId] as const,
stageLog: (id: string, stageId: string, offset = 0, limit = 65_536) =>
["runs", "stage-log", id, stageId, offset, limit] as const,
sandbox: (id: string) => ["runs", "sandbox", id] as const,
sandboxFiles: (id: string, path: string, depth?: number) =>
["runs", "sandbox-files", id, path, depth ?? null] as const,
sandboxFile: (id: string, path: string) =>
["runs", "sandbox-file", id, path] as const,
sandboxVnc: (id: string) => ["runs", "sandbox-vnc", id] as const,
sandboxServices: (id: string) => ["runs", "sandbox-services", id] as const,
preview: (id: string) => ["runs", "preview", id] as const,
cancel: (id: string) => ["runs", "cancel", id] as const,
archive: (id: string) => ["runs", "archive", id] as const,
unarchive: (id: string) => ["runs", "unarchive", id] as const,
updateTitle: (id: string) => ["runs", "update-title", id] as const,
attachUrl: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
},
workflows: {
list: () => "/api/v1/workflows",
detail: (name: string) => `/api/v1/workflows/${pathSegment(name)}`,
runs: (name: string) => `/api/v1/workflows/${pathSegment(name)}/runs`,
list: () => ["workflows", "list"] as const,
detail: (name: string) => ["workflows", "detail", name] as const,
runs: (name: string) => ["workflows", "runs", name] as const,
},
insights: {
queries: () => "/api/v1/insights/queries",
history: () => "/api/v1/insights/history",
queries: () => ["insights", "queries"] as const,
history: () => ["insights", "history"] as const,
},
settings: {
server: () => "/api/v1/settings",
server: () => ["settings", "server"] as const,
},
};

View file

@ -1,4 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { AxiosAdapter } from "axios";
import type { Run, RunStatus } from "@qltysh/fabro-api-client";
import {
archiveRun,
@ -10,29 +12,77 @@ import {
mapError,
unarchiveRun,
} from "./run-actions";
import { generatedAxios } from "./api-client";
type StubResponseInit = {
status: number;
body?: string;
body?: unknown;
statusText?: string;
};
function stubFetchOnce(init: StubResponseInit) {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() =>
Promise.resolve(
new Response(init.body ?? "", {
status: init.status,
statusText: init.statusText ?? "",
headers: { "Content-Type": "application/json" },
}),
)) as typeof fetch;
const originalAdapter = generatedAxios.defaults.adapter;
return () => {
globalThis.fetch = originalFetch;
function makeRun(status: RunStatus, archived = false): Run {
return {
id: "run-1",
goal: "Fix the build",
title: "Fix the build",
workflow: { slug: "fix_build", name: "Fix Build" },
automation: null,
repository: null,
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status,
pending_control: null,
queue_position: null,
error: null,
archived,
archived_at: archived ? "2026-04-20T12:05:00Z" : null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-04-20T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
};
}
function stubGeneratedAxiosOnce(init: StubResponseInit) {
generatedAxios.defaults.adapter = (async (config) => {
if (init.status >= 400) {
throw {
isAxiosError: true,
message: init.statusText ?? `HTTP ${init.status}`,
response: {
status: init.status,
statusText: init.statusText ?? "",
data: init.body ?? null,
headers: {},
},
};
}
return {
data: init.body,
status: init.status,
statusText: init.statusText ?? "",
headers: {},
config,
};
}) as AxiosAdapter;
}
async function expectLifecycleError(
input: Promise<unknown>,
): Promise<{ status: number; errors: Array<{ status: string; title: string; detail: string }> }> {
@ -45,68 +95,52 @@ async function expectLifecycleError(
}
describe("run lifecycle actions", () => {
let restoreFetch: (() => void) | undefined;
afterEach(() => {
restoreFetch?.();
restoreFetch = undefined;
generatedAxios.defaults.adapter = originalAdapter;
delete (globalThis as { window?: unknown }).window;
});
test("cancelRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
}),
body: makeRun({ kind: "failed", reason: "cancelled" }),
});
const result = await cancelRun("run-1");
expect(result.status.kind).toBe("failed");
if (result.status.kind === "failed") {
expect(result.status.reason).toBe("cancelled");
expect(result.lifecycle.status.kind).toBe("failed");
if (result.lifecycle.status.kind === "failed") {
expect(result.lifecycle.status.reason).toBe("cancelled");
}
});
test("archiveRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
}),
body: makeRun({ kind: "succeeded", reason: "completed" }, true),
});
const result = await archiveRun("run-1");
expect(result.status.kind).toBe("archived");
expect(result.lifecycle.status.kind).toBe("succeeded");
expect(result.lifecycle.archived).toBe(true);
});
test("unarchiveRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
}),
body: makeRun({ kind: "succeeded", reason: "completed" }),
});
const result = await unarchiveRun("run-1");
expect(result.status.kind).toBe("succeeded");
expect(result.lifecycle.status.kind).toBe("succeeded");
expect(result.lifecycle.archived).toBe(false);
});
test("404 and 409 preserve the parsed error envelope", async () => {
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 404,
body: JSON.stringify({
body: {
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
}),
},
});
const notFound = await expectLifecycleError(cancelRun("missing-run"));
expect(notFound).toEqual({
@ -114,11 +148,11 @@ describe("run lifecycle actions", () => {
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
});
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 409,
body: JSON.stringify({
body: {
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
}),
},
});
const conflict = await expectLifecycleError(archiveRun("run-1"));
expect(conflict).toEqual({
@ -128,7 +162,7 @@ describe("run lifecycle actions", () => {
});
test("non-JSON error bodies fall back to an empty error list", async () => {
restoreFetch = stubFetchOnce({
stubGeneratedAxiosOnce({
status: 409,
body: "<html>conflict</html>",
statusText: "Conflict",
@ -164,19 +198,12 @@ describe("run lifecycle actions", () => {
test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
expect(
isTerminalCancelledRun({
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
}),
isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })),
).toBe(true);
expect(
isTerminalCancelledRun({
id: "run-1",
status: { kind: "running" },
pending_control: "cancel",
created_at: "2026-04-20T12:00:00Z",
}),
isTerminalCancelledRun(
makeRun({ kind: "running" }, false),
),
).toBe(false);
});
});

View file

@ -1,7 +1,12 @@
import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client";
import type { ErrorResponseEntry, Run } from "@qltysh/fabro-api-client";
import { apiRequest } from "./api-client";
import { queryKeys } from "./query-keys";
import {
ApiError,
apiData,
apiResponse,
requestSignalOptions,
runsApi,
} from "./api-client";
import type { RunStatus } from "../data/runs";
export type LifecycleAction = "cancel" | "archive" | "unarchive";
@ -26,18 +31,27 @@ const ARCHIVABLE_STATUSES = new Set<RunStatus>([
"dead",
]);
export async function cancelRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function cancelRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "cancel", request);
}
export async function archiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function archiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "archive", request);
}
export async function unarchiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function unarchiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "unarchive", request);
}
export async function deleteRun(id: string, request?: Request): Promise<void> {
try {
await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request)));
} catch (error) {
if (error instanceof ApiError && error.status === 404) return;
throw lifecycleActionErrorFromError(error);
}
}
export function canCancel(status: string | null | undefined): boolean {
return !!status && CANCELABLE_STATUSES.has(status as RunStatus);
}
@ -50,8 +64,24 @@ export function canUnarchive(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
return run.status.kind === "failed" && run.status.reason === "cancelled";
export function canDelete(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: Run): boolean {
const status = run.lifecycle.status;
return status.kind === "failed" && status.reason === "cancelled";
}
export function deleteErrorMessage(error: unknown): string {
if (isLifecycleActionError(error)) {
if (error.status === 409) {
return "Active runs can't be deleted.";
}
const detail = error.errors[0]?.detail?.trim();
if (detail) return detail;
}
return "Couldn't delete the run right now. Try again.";
}
export function mapError(error: unknown, action: LifecycleAction): string {
@ -90,44 +120,34 @@ async function runLifecycleAction(
id: string,
action: LifecycleAction,
request?: Request,
): Promise<RunStatusResponse> {
const response = await apiRequest(queryKeys.runs[action](id), {
init: {
method: "POST",
},
request,
});
if (!response.ok) {
throw await parseLifecycleActionError(response);
): Promise<Run> {
try {
switch (action) {
case "cancel":
return await apiData(() => runsApi.cancelRun(id, requestSignalOptions(request)));
case "archive":
return await apiData(() => runsApi.archiveRun(id, requestSignalOptions(request)));
case "unarchive":
return await apiData(() => runsApi.unarchiveRun(id, requestSignalOptions(request)));
}
} catch (error) {
throw lifecycleActionErrorFromError(error);
}
return response.json() as Promise<RunStatusResponse>;
}
async function parseLifecycleActionError(response: Response): Promise<LifecycleActionError> {
let bodyText = "";
try {
bodyText = await response.text();
} catch {
// Ignore body read failures and fall back to the status only.
}
function lifecycleActionErrorFromError(error: unknown): LifecycleActionError {
if (!(error instanceof ApiError)) throw error;
return {
status: error.status,
errors: parseLifecycleErrors(error.body),
};
}
if (!bodyText) {
return { status: response.status, errors: [] };
}
try {
const body = JSON.parse(bodyText) as { errors?: unknown };
if (!Array.isArray(body.errors)) {
return { status: response.status, errors: [] };
}
const errors = body.errors.filter(isErrorResponseEntry);
return { status: response.status, errors };
} catch {
return { status: response.status, errors: [] };
}
function parseLifecycleErrors(body: unknown): ErrorResponseEntry[] {
if (!body || typeof body !== "object") return [];
const errors = (body as { errors?: unknown }).errors;
if (!Array.isArray(errors)) return [];
return errors.filter(isErrorResponseEntry);
}
export function isLifecycleActionError(value: unknown): value is LifecycleActionError {

View file

@ -4,7 +4,12 @@ import {
queryKeysForRunEvent,
subscribeToRunEvents,
} from "./run-events";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import type { EventSourceLike, SseKey } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
@ -25,52 +30,96 @@ class FakeEventSource {
}
}
class FakeBroadcastChannel implements BroadcastChannelLike {
onmessage: ((event: { data: unknown }) => void) | null = null;
postMessage() {}
close() {}
}
describe("queryKeysForRunEvent", () => {
test("terminal events invalidate run-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "run.completed")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.files("run-1"),
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stages("run-1"),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
]);
});
test("stage.retrying invalidates stages, billing, events, graph, detail, and stage events", () => {
expect(queryKeysForRunEvent("run-1", "stage.retrying", "verify@2")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.detail("run-1"),
queryKeys.runs.stageEvents("run-1", "verify@2"),
]);
});
test("stage-scoped steering events invalidate run events and stage events", () => {
expect(queryKeysForRunEvent("run-1", "agent.session.activated", "agent@1")).toEqual([
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "agent@1"),
]);
});
test("stage-scoped interrupt injection invalidates run events and stage events", () => {
expect(queryKeysForRunEvent("run-1", "agent.interrupt.injected", "nap@1")).toEqual([
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "nap@1"),
]);
});
});
describe("subscribeToRunEvents", () => {
test("refcounts shared sources and keeps mutators active until final unsubscribe", () => {
test("coordinated mode uses the global attach stream and filters by run_id", async () => {
const source = new FakeEventSource();
const created: string[] = [];
const keys: string[] = [];
const mutate = (key: string) => {
keys.push(key);
return Promise.resolve();
};
const firstCleanup = subscribeToRunEvents("run-refcount", mutate, (url) => {
const keys: SseKey[] = [];
const coordinator = createCoordinator((url) => {
created.push(url);
return source;
}, { debounceMs: 0 });
const secondCleanup = subscribeToRunEvents("run-refcount", mutate, () => {
throw new Error("source should be reused");
}, { debounceMs: 0 });
});
expect(created).toEqual(["/api/v1/runs/run-refcount/attach"]);
const cleanup = subscribeToRunEvents(
"run-coordinated",
(key) => {
keys.push(key);
return Promise.resolve();
},
() => {
throw new Error("source should be created by coordinator");
},
{ debounceMs: 0, coordinator },
);
firstCleanup();
source.emit({ event: "checkpoint.completed" });
await waitFor(() => created.length === 1);
keys.length = 0;
expect(source.closed).toBe(false);
expect(keys).toEqual([queryKeys.runs.files("run-refcount")]);
source.emit({ event: "checkpoint.completed", run_id: "other-run" });
source.emit({ event: "checkpoint.completed", run_id: "run-coordinated" });
secondCleanup();
expect(source.closed).toBe(true);
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-coordinated"),
queryKeys.runs.commits("run-coordinated"),
]);
cleanup();
coordinator.close();
});
test("terminal events close the source after invalidating keys", () => {
test("coordinated terminal events invalidate without closing the global stream", async () => {
const source = new FakeEventSource();
const keys: string[] = [];
const keys: SseKey[] = [];
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
"run-terminal",
(key) => {
@ -78,23 +127,182 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => source,
{ debounceMs: 0 },
{ debounceMs: 0, coordinator },
);
await waitFor(() => source.onmessage !== null);
keys.length = 0;
source.emit({ event: "run.failed", run_id: "run-terminal" });
expect(source.closed).toBe(false);
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
keys.length = 0;
source.emit({ event: "run.archived", run_id: "run-terminal" });
expect(source.closed).toBe(false);
expect(keys).toEqual([queryKeys.runs.detail("run-terminal")]);
cleanup();
coordinator.close();
});
test("fallback refcounts run-scoped sources and keeps mutators active until final unsubscribe", () => {
const source = new FakeEventSource();
const created: string[] = [];
const keys: SseKey[] = [];
const coordinator = createFallbackCoordinator();
const mutate = (key: SseKey) => {
keys.push(key);
return Promise.resolve();
};
const firstCleanup = subscribeToRunEvents("run-refcount", mutate, (url) => {
created.push(url);
return source;
}, { debounceMs: 0, coordinator });
const secondCleanup = subscribeToRunEvents("run-refcount", mutate, () => {
throw new Error("source should be reused");
}, { debounceMs: 0, coordinator });
expect(created).toEqual(["/api/v1/runs/run-refcount/attach"]);
firstCleanup();
source.emit({ event: "checkpoint.completed" });
expect(source.closed).toBe(false);
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-refcount"),
queryKeys.runs.commits("run-refcount"),
]);
secondCleanup();
expect(source.closed).toBe(true);
coordinator.close();
});
test("fallback runs payload callbacks for later subscribers on a shared source", () => {
const source = new FakeEventSource();
const seen: string[] = [];
const keys: SseKey[] = [];
const coordinator = createFallbackCoordinator();
const mutate = (key: SseKey) => {
keys.push(key);
return Promise.resolve();
};
const callbackMutate = () => Promise.resolve();
const firstCleanup = subscribeToRunEvents("run-shared-payload", mutate, () => source, {
debounceMs: 0,
coordinator,
});
const secondCleanup = subscribeToRunEvents("run-shared-payload", callbackMutate, () => {
throw new Error("source should be reused");
}, {
debounceMs: 0,
coordinator,
onEvent: (payload) => {
if (payload.event) seen.push(payload.event);
},
});
source.emit({ id: "evt-1", event: "agent.steer.buffered", properties: {} });
expect(seen).toEqual(["agent.steer.buffered"]);
expect(keys).toEqual([queryKeys.runs.events("run-shared-payload", 1000)]);
firstCleanup();
secondCleanup();
coordinator.close();
});
test("fallback terminal events close the source after invalidating keys", () => {
const source = new FakeEventSource();
const keys: SseKey[] = [];
const coordinator = createFallbackCoordinator();
const cleanup = subscribeToRunEvents(
"run-terminal",
(key) => {
keys.push(key);
return Promise.resolve();
},
() => source,
{ debounceMs: 0, coordinator },
);
source.emit({ event: "run.failed" });
expect(source.closed).toBe(true);
expect(keys).toContain(queryKeys.runs.files("run-terminal"));
expect(keys).toContain(queryKeys.runs.billing("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
cleanup();
coordinator.close();
});
test("malformed events are ignored and StrictMode-style cleanup does not underflow", () => {
test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => {
const source = new FakeEventSource();
const keys: SseKey[] = [];
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
"run-stage",
(key) => {
keys.push(key);
return Promise.resolve();
},
() => source,
{ debounceMs: 0, coordinator },
);
await waitFor(() => source.onmessage !== null);
source.emit({
event: "stage.retrying",
run_id: "run-stage",
stage_id: "verify@2",
node_id: "verify",
});
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2"));
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage"));
expect(keys).toContainEqual(queryKeys.runs.events("run-stage", 1000));
expect(keys).toContainEqual(queryKeys.runs.graph("run-stage", "LR"));
expect(keys).toContainEqual(queryKeys.runs.detail("run-stage"));
expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify"));
cleanup();
coordinator.close();
});
test("falls back to node_id when an event has no stage_id", async () => {
const source = new FakeEventSource();
const keys: SseKey[] = [];
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
"run-stage-node",
(key) => {
keys.push(key);
return Promise.resolve();
},
() => source,
{ debounceMs: 0, coordinator },
);
await waitFor(() => source.onmessage !== null);
source.emit({ event: "stage.started", run_id: "run-stage-node", node_id: "verify" });
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage-node", "verify"));
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage-node"));
cleanup();
coordinator.close();
});
test("fallback malformed events are ignored and StrictMode-style cleanup does not underflow", () => {
const firstSource = new FakeEventSource();
const secondSource = new FakeEventSource();
const sources = [firstSource, secondSource];
const keys: string[] = [];
const keys: SseKey[] = [];
const coordinator = createFallbackCoordinator();
const firstCleanup = subscribeToRunEvents(
"run-strict",
@ -103,7 +311,7 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => sources.shift()!,
{ debounceMs: 0 },
{ debounceMs: 0, coordinator },
);
firstSource.emitRaw("{broken");
firstCleanup();
@ -115,12 +323,45 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => sources.shift()!,
{ debounceMs: 0 },
{ debounceMs: 0, coordinator },
);
secondCleanup();
expect(keys).toEqual([]);
expect(firstSource.closed).toBe(true);
expect(secondSource.closed).toBe(true);
coordinator.close();
});
});
function createCoordinator(eventSourceFactory: (url: string) => EventSourceLike) {
return createCrossTabSseCoordinator({
tabId: "run-test",
channelFactory: () => new FakeBroadcastChannel(),
eventSourceFactory,
addVisibilityChangeListener: () => () => {},
addPagehideListener: () => () => {},
timing: {
heartbeatMs: 10,
leaderStaleMs: 50,
electionJitterMs: 0,
},
});
}
function createFallbackCoordinator() {
return createCrossTabSseCoordinator({
channelFactory: () => {
throw new Error("BroadcastChannel unavailable");
},
});
}
async function waitFor(condition: () => boolean, timeoutMs = 200) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error("condition did not become true before timeout");
}

View file

@ -1,6 +1,10 @@
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
@ -8,15 +12,26 @@ import {
type EventPayload,
type EventSourceLike,
type MutateFn,
type SseKey,
type SharedEventSubscription,
} from "./sse";
interface RunEventPayload extends EventPayload {
export interface RunEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
run_id?: string;
node_id?: string;
stage_id?: string;
properties?: Record<string, unknown>;
}
interface RunEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
onEvent?: (payload: RunEventPayload) => void;
}
const subscriptions = new Map<string, SharedEventSubscription>();
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]);
@ -31,29 +46,70 @@ const RUN_SUMMARY_EVENTS = new Set([
"run.unblocked",
"run.archived",
"run.unarchived",
"run.title.updated",
]);
const STAGE_EVENTS = new Set(["stage.started", "stage.completed", "stage.failed"]);
const COMMAND_EVENTS = new Set(["command.started", "command.completed"]);
const STAGE_EVENTS = new Set([
"stage.started",
"stage.completed",
"stage.failed",
"stage.retrying",
]);
// Single source of truth: every event type the `eventsToActivity` reducer in
// `routes/run-stages.tsx` consumes. When any of these arrive for a stage we
// currently view, the stage-events SWR key for that stage must be invalidated
// so the panel refetches. The reducer imports this list so the switch stays
// in sync with the invalidation set; if the reducer grows a new case, this
// list is the single edit point.
//
// The lifecycle `STAGE_EVENTS` set is kept separate because it also fans out
// to run-scoped invalidations (stages list, graph, detail).
export const STAGE_ACTIVITY_EVENT_TYPES = [
"stage.prompt",
"prompt.completed",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"agent.steering.injected",
"agent.interrupt.injected",
"command.started",
"command.completed",
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
const STEERING_EVENTS = new Set([
"run.interrupt",
"run.steer",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.session.activated",
"agent.session.deactivated",
"agent.steer.buffered",
"agent.steer.dropped",
]);
export function queryKeysForRunEvent(
runId: string,
event: string,
stageId?: string,
): string[] {
): SseKey[] {
if (event === "checkpoint.completed") {
return [queryKeys.runs.files(runId)];
return [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
];
}
if (TERMINAL_EVENTS.has(event)) {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.files(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.graph(runId, "LR"),
@ -73,30 +129,32 @@ export function queryKeysForRunEvent(
}
if (STAGE_EVENTS.has(event)) {
const keys = [
const keys: SseKey[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.detail(runId),
];
if (stageId) {
keys.push(queryKeys.runs.stageTurns(runId, stageId));
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (COMMAND_EVENTS.has(event)) {
const keys = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
];
if (STEERING_EVENTS.has(event)) {
const keys: SseKey[] = [queryKeys.runs.events(runId, 1000)];
if (stageId) {
keys.push(queryKeys.runs.stageTurns(runId, stageId));
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STAGE_ACTIVITY_EVENTS.has(event)) {
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
}
return [];
}
@ -104,32 +162,62 @@ export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300 }: { debounceMs?: number } = {},
{ debounceMs = 300, coordinator, onEvent }: RunEventOptions = {},
): () => void {
return subscribeToSharedEventSource<RunEventPayload>({
subscriptions,
subscriptionKey: runId,
url: queryKeys.runs.attach(runId),
return subscribeToCrossTabSse<RunEventPayload>({
coordinator,
subscriptionKey: `run:${runId}`,
mutate,
eventSourceFactory,
debounceMs,
resyncKeys: () => resyncKeysForRun(runId),
resolveInvalidation: (payload) => {
const event = payload.event;
if (!event) return { keys: [] };
const stageId = stageIdFromPayload(payload);
const keys = queryKeysForRunEvent(runId, event, stageId);
const terminal = TERMINAL_EVENTS.has(event);
return {
keys,
close: terminal,
immediate: terminal,
};
if (payload.run_id !== runId) return { keys: [] };
onEvent?.(payload);
return runInvalidation(runId, payload);
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<RunEventPayload>({
subscriptions,
subscriptionKey: runId,
url: queryKeys.runs.attachUrl(runId),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
onEvent?.(payload);
const result = runInvalidation(runId, payload);
return { ...result, close: result.immediate };
},
}),
});
}
function runInvalidation(runId: string, payload: RunEventPayload) {
const event = payload.event;
if (!event) return { keys: [], immediate: false };
const stageId = stageIdFromPayload(payload);
const keys = queryKeysForRunEvent(runId, event, stageId);
const terminal = TERMINAL_EVENTS.has(event);
return { keys, immediate: terminal };
}
function resyncKeysForRun(runId: string) {
return [
queryKeys.runs.detail(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.questions(runId, 25, 0),
];
}
function stageIdFromPayload(payload: RunEventPayload): string | undefined {
if (typeof payload.stage_id === "string") return payload.stage_id;
if (typeof payload.node_id === "string") return payload.node_id;
const nodeId = payload.properties?.node_id;
return typeof nodeId === "string" ? nodeId : undefined;

View file

@ -1,6 +1,7 @@
import type { MutatorCallback } from "swr";
import type { Key, MutatorCallback } from "swr";
export type MutateFn = (key: string) => ReturnType<MutatorCallback>;
export type SseKey = Key;
export type MutateFn = (key: SseKey) => ReturnType<MutatorCallback>;
export interface EventPayload {
event?: string;
@ -13,19 +14,26 @@ export interface EventSourceLike {
}
export interface EventInvalidation {
keys: string[];
keys: SseKey[];
close?: boolean;
immediate?: boolean;
}
type EventResolver = (payload: EventPayload) => EventInvalidation;
export interface SharedEventSubscription {
source: EventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
pendingKeys: Set<string>;
resolvers: Map<symbol, EventResolver>;
pendingKeys: Map<string, SseKey>;
debounceTimer: ReturnType<typeof setTimeout> | null;
}
export function sseKeyDedupeId(key: SseKey): string {
return stringifyKeyValue(key);
}
export function createBrowserEventSource(url: string): EventSourceLike {
return new EventSource(url);
}
@ -54,7 +62,8 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
source,
refcount: 0,
mutators: new Map(),
pendingKeys: new Set(),
resolvers: new Map(),
pendingKeys: new Map(),
debounceTimer: null,
};
subscriptions.set(subscriptionKey, subscription);
@ -70,18 +79,31 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
return;
}
const invalidation = resolveInvalidation(payload);
queueInvalidations(current, invalidation.keys, {
debounceMs,
immediate: invalidation.immediate,
});
const keys = new Map<string, SseKey>();
let close = false;
let immediate = false;
for (const resolver of current.resolvers.values()) {
const invalidation = resolver(payload);
for (const key of invalidation.keys) {
keys.set(sseKeyDedupeId(key), key);
}
close ||= Boolean(invalidation.close);
immediate ||= Boolean(invalidation.immediate);
}
if (invalidation.close) {
queueInvalidations(current, [...keys.values()], { debounceMs, immediate });
if (close) {
closeSharedEventSource(subscriptions, subscriptionKey, { flushPending: true });
}
};
}
const resolverId = Symbol(subscriptionKey);
subscription.resolvers.set(
resolverId,
resolveInvalidation as EventResolver,
);
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
@ -89,6 +111,8 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
const current = subscriptions.get(subscriptionKey);
if (!current) return;
current.resolvers.delete(resolverId);
const mutateCount = current.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
current.mutators.delete(mutate);
@ -105,7 +129,7 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
function queueInvalidations(
subscription: SharedEventSubscription,
keys: string[],
keys: SseKey[],
{
debounceMs,
immediate,
@ -116,7 +140,7 @@ function queueInvalidations(
) {
if (keys.length === 0) return;
for (const key of keys) {
subscription.pendingKeys.add(key);
subscription.pendingKeys.set(sseKeyDedupeId(key), key);
}
if (immediate || debounceMs <= 0) {
@ -135,7 +159,7 @@ function queueInvalidations(
function flushInvalidations(subscription: SharedEventSubscription) {
if (subscription.pendingKeys.size === 0) return;
const keys = [...subscription.pendingKeys];
const keys = [...subscription.pendingKeys.values()];
subscription.pendingKeys.clear();
for (const mutator of subscription.mutators.keys()) {
@ -162,3 +186,17 @@ function closeSharedEventSource(
subscription.source.close();
subscriptions.delete(subscriptionKey);
}
function stringifyKeyValue(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stringifyKeyValue(item)).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stringifyKeyValue(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? String(value);
}

View file

@ -0,0 +1,200 @@
import { describe, expect, test } from "bun:test";
import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
id: `${nodeId}@${visit}`,
name: nodeId,
handler: "agent",
nodeId,
visit,
status,
duration: "--",
startedAt: null,
};
}
describe("mapRunStagesToSidebarStages", () => {
test("maps two visits of the same node to distinct sidebar entries", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "apply-changes@1",
name: "Apply Changes",
handler: "command",
status: "succeeded",
duration_secs: 12.5,
node_id: "apply",
visit: 1,
},
{
id: "apply-changes@2",
name: "Apply Changes",
handler: "agent",
status: "running",
node_id: "apply",
visit: 2,
},
],
meta: { has_more: false },
};
const result = mapRunStagesToSidebarStages(stages);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("apply-changes@1");
expect(result[0].handler).toBe("command");
expect(result[0].nodeId).toBe("apply");
expect(result[0].visit).toBe(1);
expect(formatStageLabel(result[0])).toBe("Apply Changes");
expect(result[1].id).toBe("apply-changes@2");
expect(result[1].handler).toBe("agent");
expect(result[1].nodeId).toBe("apply");
expect(result[1].visit).toBe(2);
expect(formatStageLabel(result[1])).toBe("Apply Changes (2)");
});
test("filters by node_id (suffixed start@1 / exit@1 are still hidden)", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "start@1",
name: "start",
handler: "start",
status: "succeeded",
node_id: "start",
visit: 1,
},
{
id: "verify@1",
name: "verify",
handler: "human",
status: "succeeded",
node_id: "verify",
visit: 1,
},
{
id: "exit@1",
name: "exit",
handler: "exit",
status: "succeeded",
node_id: "exit",
visit: 1,
},
],
meta: { has_more: false },
};
const result = mapRunStagesToSidebarStages(stages);
expect(result.map((s) => s.id)).toEqual(["verify@1"]);
});
test("missing duration renders as '--'", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "verify@1",
name: "verify",
handler: "wait",
status: "running",
node_id: "verify",
visit: 1,
},
],
meta: { has_more: false },
};
expect(mapRunStagesToSidebarStages(stages)[0].duration).toBe("--");
});
test("preserves the authoritative handler for renderer dispatch", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "approval@1",
name: "approval",
handler: "human" satisfies StageHandler,
status: "pending",
node_id: "approval",
visit: 1,
},
],
meta: { has_more: false },
};
expect(mapRunStagesToSidebarStages(stages)[0].handler).toBe("human");
});
});
describe("aggregateGraphNodeStatus", () => {
test("(failed, running) renders as running and clicks open the latest visit", () => {
const result = aggregateGraphNodeStatus([
makeStage("verify", 1, "failed"),
makeStage("verify", 2, "running"),
]);
expect(result.get("verify")).toEqual({
displayStatus: "running",
latestStageId: "verify@2",
});
});
test("(failed, succeeded) renders as succeeded — failure-then-fix shows healed", () => {
const result = aggregateGraphNodeStatus([
makeStage("verify", 1, "failed"),
makeStage("verify", 2, "succeeded"),
]);
expect(result.get("verify")).toEqual({
displayStatus: "succeeded",
latestStageId: "verify@2",
});
});
test("(succeeded, failed) renders as failed and clicks open the latest visit", () => {
const result = aggregateGraphNodeStatus([
makeStage("verify", 1, "succeeded"),
makeStage("verify", 2, "failed"),
]);
expect(result.get("verify")).toEqual({
displayStatus: "failed",
latestStageId: "verify@2",
});
});
test("(running, retrying) — latest active wins", () => {
const result = aggregateGraphNodeStatus([
makeStage("verify", 1, "running"),
makeStage("verify", 2, "retrying"),
]);
expect(result.get("verify")).toEqual({
displayStatus: "retrying",
latestStageId: "verify@2",
});
});
test("orders by visit even when input is shuffled", () => {
const result = aggregateGraphNodeStatus([
makeStage("verify", 2, "running"),
makeStage("verify", 1, "failed"),
]);
expect(result.get("verify")?.latestStageId).toBe("verify@2");
});
test("single visit per node is unaffected", () => {
const result = aggregateGraphNodeStatus([
makeStage("plan", 1, "succeeded"),
makeStage("apply", 1, "running"),
]);
expect(result.get("plan")).toEqual({
displayStatus: "succeeded",
latestStageId: "plan@1",
});
expect(result.get("apply")).toEqual({
displayStatus: "running",
latestStageId: "apply@1",
});
});
});

View file

@ -1,27 +1,115 @@
import type { PaginatedRunStageList, StageState } from "@qltysh/fabro-api-client";
import { StageState } from "@qltysh/fabro-api-client";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "./format";
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set(["running", "retrying"]);
export const SUCCEEDED_STAGE_STATES: ReadonlySet<StageState> = new Set([
"succeeded",
"partially_succeeded",
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
StageState.RUNNING,
StageState.RETRYING,
]);
export const IN_FLIGHT_STAGE_STATES: ReadonlySet<StageState> = new Set([
StageState.PENDING,
StageState.RUNNING,
StageState.RETRYING,
]);
export const SUCCEEDED_STAGE_STATES: ReadonlySet<StageState> = new Set([
StageState.SUCCEEDED,
StageState.PARTIALLY_SUCCEEDED,
]);
const STAGE_STATUS_TONE: Record<StageState, string> = {
pending: "bg-overlay-strong text-fg-muted",
running: "bg-teal-500/15 text-teal-500",
retrying: "bg-amber/15 text-amber",
succeeded: "bg-mint/15 text-mint",
partially_succeeded: "bg-amber/15 text-amber",
failed: "bg-coral/15 text-coral",
skipped: "bg-overlay-strong text-fg-muted",
cancelled: "bg-overlay-strong text-fg-muted",
};
const STAGE_STATUS_LABEL: Record<StageState, string> = {
pending: "Pending",
running: "Running",
retrying: "Retrying",
succeeded: "Succeeded",
partially_succeeded: "Partial",
failed: "Failed",
skipped: "Skipped",
cancelled: "Cancelled",
};
export function stageStatusTone(status: StageState): string {
return STAGE_STATUS_TONE[status];
}
export function stageStatusLabel(status: StageState): string {
return STAGE_STATUS_LABEL[status];
}
/**
* Display label for a stage. Suffixes `(N)` for visits > 1 so a looped node
* (e.g. `verify`) renders as `verify`, `verify (2)`, `verify (3)` in the
* sidebar and stage header.
*/
export function formatStageLabel(stage: { name: string; visit: number }): string {
return stage.visit > 1 ? `${stage.name} (${stage.visit})` : stage.name;
}
export function mapRunStagesToSidebarStages(
stagesResult: PaginatedRunStageList | null | undefined,
): Stage[] {
return (stagesResult?.data ?? [])
.filter((stage) => isVisibleStage(stage.id))
.filter((stage) => isVisibleStage(stage.node_id))
.map((stage) => ({
id: stage.id,
name: stage.name,
dotId: stage.dot_id ?? stage.id,
handler: stage.handler,
nodeId: stage.node_id,
visit: stage.visit,
status: stage.status,
duration: stage.duration_secs != null
? formatDurationSecs(stage.duration_secs)
: "--",
startedAt: stage.started_at ?? null,
}));
}
/**
* Aggregate per-node display state for the workflow graph.
*
* Status policy: if any visit is active (running/retrying), the node renders
* that active state (latest active visit wins). Otherwise the node renders
* the latest visit's terminal state. The click target is always the latest
* visit's stageId.
*/
export function aggregateGraphNodeStatus(stages: readonly Stage[]): Map<
string,
{ displayStatus: StageState; latestStageId: string }
> {
// Single pass per nodeId: track the visit with the highest `visit` overall
// (drives click target + terminal status) and the highest-visit *active*
// stage (drives display when any visit is in flight).
const latest = new Map<string, Stage>();
const latestActive = new Map<string, Stage>();
for (const stage of stages) {
const prevLatest = latest.get(stage.nodeId);
if (!prevLatest || stage.visit > prevLatest.visit) {
latest.set(stage.nodeId, stage);
}
if (ACTIVE_STAGE_STATES.has(stage.status)) {
const prevActive = latestActive.get(stage.nodeId);
if (!prevActive || stage.visit > prevActive.visit) {
latestActive.set(stage.nodeId, stage);
}
}
}
const result = new Map<string, { displayStatus: StageState; latestStageId: string }>();
for (const [nodeId, latestStage] of latest) {
const display = latestActive.get(nodeId) ?? latestStage;
result.set(nodeId, { displayStatus: display.status, latestStageId: latestStage.id });
}
return result;
}

View file

@ -1,3 +1,21 @@
import { useEffect, useState } from "react";
/**
* Re-renders the calling component every `intervalMs` milliseconds while
* `active` is true, returning the current `Date.now()` value at each tick.
* Returns the captured value when paused, so renders are stable.
*/
export function useTickingNow(active: boolean, intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const interval = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(interval);
}, [active, intervalMs]);
return now;
}
function relativeTime(seconds: number, past: boolean): string {
if (seconds < 60) return past ? "just now" : "in <1m";
const minutes = Math.floor(seconds / 60);
@ -21,4 +39,3 @@ export function timeAgo(iso: string): string {
export function timeUntil(iso: string): string {
return relativeTime(Math.floor((new Date(iso).getTime() - Date.now()) / 1000), false);
}

View file

@ -1,34 +0,0 @@
import type { PaginationMeta, WorkflowSettings } from "@qltysh/fabro-api-client";
export type WorkflowSettingsSnapshot = WorkflowSettings;
export interface WorkflowScheduleSummary {
expression: string;
next_run?: string | null;
}
export interface WorkflowLastRunSummary {
ran_at?: string | null;
}
export interface WorkflowListItem {
name: string;
slug: string;
filename: string;
last_run?: WorkflowLastRunSummary | null;
schedule?: WorkflowScheduleSummary | null;
}
export interface PaginatedWorkflowListResponse {
data: WorkflowListItem[];
pagination?: PaginationMeta;
}
export interface WorkflowDetailResponse {
name: string;
slug: string;
description: string;
filename: string;
settings: WorkflowSettingsSnapshot;
graph: string;
}

View file

@ -6,24 +6,33 @@ import * as RedirectHome from "./routes/redirect-home";
import * as Setup from "./routes/setup";
import * as AuthLogin from "./routes/auth-login";
import * as Start from "./routes/start";
import * as Workflows from "./routes/workflows";
import * as WorkflowDetail from "./routes/workflow-detail";
import * as WorkflowDefinition from "./routes/workflow-definition";
import * as WorkflowDiagram from "./routes/workflow-diagram";
import * as WorkflowRuns from "./routes/workflow-runs";
import * as Automations from "./routes/automations";
import * as AutomationDetail from "./routes/automation-detail";
import * as AutomationDefinition from "./routes/automation-definition";
import * as AutomationDiagram from "./routes/automation-diagram";
import * as AutomationRuns from "./routes/automation-runs";
import * as Runs from "./routes/runs";
import * as RunDetail from "./routes/run-detail";
import * as RunOverview from "./routes/run-overview";
import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
import * as RunGraph from "./routes/run-graph";
import * as RunSource from "./routes/run-source";
import * as RunLogs from "./routes/run-logs";
import * as RunEvents from "./routes/run-events";
import * as RunArtifacts from "./routes/run-artifacts";
import * as RunFiles from "./routes/run-files";
import * as RunSandbox from "./routes/run-sandbox";
import * as RunTerminal from "./routes/run-terminal";
import * as RunBilling from "./routes/run-billing";
import * as Insights from "./routes/insights";
import * as InsightsEditor from "./routes/insights-editor";
import * as InsightsNew from "./routes/insights-new";
import * as Settings from "./routes/settings";
import * as SettingsOverview from "./routes/settings-overview";
import * as SettingsLiveEvents from "./routes/settings-live-events";
import * as Profile from "./routes/profile";
import * as ProfileOverview from "./routes/profile-overview";
import * as ProfileSessions from "./routes/profile-sessions";
import AppShellModule from "./layouts/app-shell";
type RouteModule = {
@ -71,18 +80,19 @@ export const routes: RouteObject[] = [
indexRoute(RedirectHome),
route("setup", Setup),
route("login", AuthLogin),
route("runs/:id/terminal", RunTerminal),
{
Component: withRouteModule({
default: AppShellModule,
}),
children: [
route("start", Start),
route("workflows", Workflows),
route("workflows/:name", WorkflowDetail, {
route("automations", Automations),
route("automations/:name", AutomationDetail, {
children: [
indexRoute(WorkflowDefinition),
route("diagram", WorkflowDiagram),
route("runs", WorkflowRuns),
indexRoute(AutomationDefinition),
route("diagram", AutomationDiagram),
route("runs", AutomationRuns),
],
}),
route("runs", Runs),
@ -92,9 +102,12 @@ export const routes: RouteObject[] = [
route("stages", RunStages),
route("stages/:stageId", RunStages),
route("settings", RunSettings),
route("graph", RunGraph),
route("source", RunSource),
route("logs", RunLogs),
route("events", RunEvents),
route("artifacts", RunArtifacts),
route("files", RunFiles),
route("sandbox", RunSandbox),
route("billing", RunBilling),
],
}),
@ -104,7 +117,18 @@ export const routes: RouteObject[] = [
route("new", InsightsNew),
],
}),
route("settings", Settings),
route("settings", Settings, {
children: [
indexRoute(SettingsOverview),
route("live-events", SettingsLiveEvents),
],
}),
route("profile", Profile, {
children: [
indexRoute(ProfileOverview),
route("sessions", ProfileSessions),
],
}),
],
},
],

View file

@ -2,10 +2,10 @@ import { useEffect, useState } from "react";
import { useOutletContext, useParams } from "react-router";
import type { BundledLanguage } from "@pierre/diffs";
import { registerDotLanguage } from "../data/register-dot-language";
import { workflowData, type WorkflowEntry } from "./workflow-detail";
import { workflowData, type WorkflowEntry } from "./automation-detail";
import { CollapsibleFile } from "../components/collapsible-file";
export default function WorkflowDefinition() {
export default function AutomationDefinition() {
const { name } = useParams();
const context = useOutletContext<{ workflow?: WorkflowEntry } | null>();
const workflow = context?.workflow ?? workflowData[name ?? ""];

View file

@ -1,10 +1,10 @@
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type {
WorkflowDetailResponse as ApiWorkflowDetail,
WorkflowSettings as WorkflowSettingsSnapshot,
} from "@qltysh/fabro-api-client";
import { Link, Outlet, useLocation, useParams } from "react-router";
import { useWorkflow } from "../lib/queries";
import type {
WorkflowSettingsSnapshot,
WorkflowDetailResponse as ApiWorkflowDetail,
} from "../lib/workflow-api";
export interface WorkflowEntry {
name: string;
@ -44,7 +44,6 @@ function sampleSettings({
project: {
name: null,
description: null,
directory: ".",
metadata: {},
},
workflow: {
@ -61,14 +60,14 @@ function sampleSettings({
model: { provider: null, name: "claude-sonnet", fallbacks: [] },
git: { author: null },
prepare: { commands: prepareCommands, timeout_ms: 120_000 },
execution: { mode: "normal", approval: "prompt", retros: true },
execution: { mode: "normal", approval: "prompt" },
checkpoint: { exclude_globs: [] },
sandbox: {
provider: "daytona",
preserve: false,
stop_on_terminal: true,
devcontainer: true,
env: {},
local: { worktree_mode: "dirty" },
docker: null,
daytona: {
auto_stop_interval: autoStopInterval,
@ -85,12 +84,13 @@ function sampleSettings({
},
},
notifications: {},
interviews: { provider: null, slack: null, discord: null, teams: null },
interviews: { provider: null, slack: null },
agent: { permissions: null, mcps: {} },
hooks: [],
scm: { provider: null, owner: null, repository: null, github: null },
pull_request: null,
artifacts: { include: [] },
integrations: { github: { permissions: {} } },
},
};
}
@ -310,21 +310,21 @@ function resolveWorkflow(name: string | undefined, apiWorkflow: ApiWorkflowDetai
}
export function meta({ data }: any) {
const title = data?.workflow?.name ?? "Workflow";
const title = data?.workflow?.name ?? "Automation";
return [{ title: `${title} — Fabro` }];
}
export default function WorkflowDetail() {
export default function AutomationDetail() {
const { name } = useParams();
const workflowQuery = useWorkflow(name);
const { pathname } = useLocation();
const workflow = resolveWorkflow(name, workflowQuery.data);
const basePath = `/workflows/${name}`;
const basePath = `/automations/${name}`;
return (
<div>
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
<Link to="/workflows" className="text-fg-3 hover:text-fg">Workflows</Link>
<Link to="/automations" className="text-fg-3 hover:text-fg">Automations</Link>
<ChevronRightIcon className="size-3" />
<span>{workflow.name}</span>
</nav>
@ -339,7 +339,7 @@ export default function WorkflowDetail() {
</div>
<button
type="button"
title="Run workflow"
title="Run automation"
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3.5" aria-hidden="true">

View file

@ -67,7 +67,7 @@ function stripGraphTitle(svg: SVGSVGElement) {
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
const DEFAULT_ZOOM_INDEX = 2; // 75%
export default function WorkflowDiagram() {
export default function AutomationDiagram() {
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);

View file

@ -1,7 +1,9 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { useWorkflowRuns } from "../lib/queries";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
@ -10,7 +12,7 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
const apiRuns = result?.data ?? [];
return apiRuns
.map((r) => {
const column = columnForStatus(r.status);
const column = columnForRun(r);
if (column == null) return null;
return {
...mapRunSummaryToRunItem(r),
@ -21,18 +23,11 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
.filter((run): run is RunWithStatus => run != null);
}
function GitPullRequestIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
}
function RunRow({ run }: { run: RunWithStatus }) {
const colors = columnStatusDisplay[run.status];
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<Link to={`/runs/${run.id}`} className="contents">
<span className="flex items-center gap-2 pr-2">
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
@ -43,7 +38,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
</span>
<span className="flex items-center gap-2 min-w-0">
<span className="truncate text-sm text-fg-2">{run.title}</span>
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
{run.comments != null && run.comments > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
@ -58,21 +53,20 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
</span>
</Link>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.number != null && (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
</PullRequestChip>
)}
</span>
</Link>
</div>
);
}
export default function WorkflowRuns() {
export default function AutomationRuns() {
const { name } = useParams();
const runsQuery = useWorkflowRuns(name);
const runs = mapWorkflowRuns(runsQuery.data);

View file

@ -12,13 +12,13 @@ import {
ShieldCheckIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
import type { PaginatedWorkflowListResponse } from "@qltysh/fabro-api-client";
import { Link } from "react-router";
import { useWorkflows } from "../lib/queries";
import { timeAgo, timeUntil } from "../lib/time";
import type { PaginatedWorkflowListResponse } from "../lib/workflow-api";
export function meta({}: any) {
return [{ title: "Workflows — Fabro" }];
return [{ title: "Automations — Fabro" }];
}
export const handle = {
@ -29,7 +29,7 @@ export const handle = {
className="inline-flex items-center gap-1.5 rounded-l-md border border-r-0 border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<PlusIcon className="size-3.5" aria-hidden="true" />
Create Workflow
Create Automation
</button>
<Menu as="div" className="relative -ml-px flex">
<MenuButton className="inline-flex items-center rounded-r-md border border-mint/20 px-1.5 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg">
@ -145,7 +145,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
const Icon = workflow.icon;
return (
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
<Link to={`/workflows/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
<Link to={`/automations/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-panel-alt/60"
style={{ borderColor: `color-mix(in srgb, ${workflow.color} 20%, transparent)`, color: workflow.color }}
@ -181,7 +181,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
) : (
<button
type="button"
title="Run workflow"
title="Run automation"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<PlayIcon className="size-3.5" />
@ -201,7 +201,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
type TriggerFilter = "all" | "scheduled" | "manual";
export default function Workflows() {
export default function Automations() {
const workflowsQuery = useWorkflows();
const workflows = enrichWorkflows(mapWorkflows(workflowsQuery.data));
const [query, setQuery] = useState("");
@ -222,7 +222,7 @@ export default function Workflows() {
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
placeholder="Search workflows..."
placeholder="Search automations..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
@ -246,7 +246,7 @@ export default function Workflows() {
<WorkflowCard key={workflow.filename} workflow={workflow} />
))}
{filtered.length === 0 && (
<p className="py-8 text-center text-sm text-fg-muted">No workflows match "{query}"</p>
<p className="py-8 text-center text-sm text-fg-muted">No automations match "{query}"</p>
)}
</div>
</div>

View file

@ -0,0 +1,69 @@
import type { AuthSessionUser } from "@qltysh/fabro-api-client";
import { useAuthMe } from "../lib/queries";
import {
Mono,
Muted,
Panel,
PanelSkeleton,
Row,
UrlValue,
} from "../components/settings-panel";
export default function ProfileOverview() {
const { data: auth } = useAuthMe();
if (!auth) {
return (
<div className="space-y-6">
<PanelSkeleton />
<PanelSkeleton />
</div>
);
}
const { user } = auth;
return (
<div className="space-y-6">
<BasicsPanel user={user} />
<IdentityPanel user={user} />
</div>
);
}
function BasicsPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Basics">
<Row title="Name">
<div className="flex items-center gap-3">
<img
alt=""
src={user.avatarUrl}
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
/>
<span>{user.name}</span>
</div>
</Row>
<Row title="Username">
<Mono>{user.login}</Mono>
</Row>
<Row title="Email">{user.email}</Row>
</Panel>
);
}
function IdentityPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Identity">
<Row title="Issuer">
{user.idpIssuer ? <Mono>{user.idpIssuer}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="Subject">
{user.idpSubject ? <Mono>{user.idpSubject}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="Profile URL">
<UrlValue url={user.userUrl} />
</Row>
</Panel>
);
}

View file

@ -0,0 +1,188 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import type {
AuthSession,
AuthSessionsResponse,
} from "@qltysh/fabro-api-client";
let currentResponse: AuthSessionsResponse | undefined;
const deleteAuthSessionMock = mock((_id: string) => Promise.resolve({ data: undefined }));
const mutateMock = mock((..._args: unknown[]) => Promise.resolve(undefined));
mock.module("../lib/queries", () => ({
useAuthSessions: () => ({ data: currentResponse, error: undefined }),
}));
mock.module("../lib/api-client", () => ({
apiData: async function apiData<T>(
call: () => Promise<{ data: T }>,
): Promise<T> {
const response = await call();
return response.data;
},
authApi: {
deleteAuthSession: (id: string) => deleteAuthSessionMock(id),
},
ApiError: class ApiError extends Error {
readonly status: number;
readonly requestId: string | null;
readonly body: unknown;
constructor({
status,
message,
requestId,
body,
}: {
status: number;
message: string;
requestId: string | null;
body: unknown;
}) {
super(message);
this.name = "ApiError";
this.status = status;
this.requestId = requestId;
this.body = body;
}
},
}));
mock.module("swr", () => ({
useSWRConfig: () => ({ mutate: mutateMock }),
}));
const { default: ProfileSessions } = await import("./profile-sessions");
const browserSession: AuthSession = {
id: "browser:current",
kind: "browser",
current: true,
provider: "github",
login: "alice",
label: "This browser",
createdAt: "2026-05-10T10:00:00Z",
lastSeenAt: "2026-05-10T12:00:00Z",
expiresAt: "2026-05-17T10:00:00Z",
revocable: false,
};
const cliSession: AuthSession = {
id: "cli:abcd-1234",
kind: "cli",
current: false,
provider: "github",
login: "alice",
label: "Fabro CLI",
userAgent: "fabro/0.1.0 Darwin",
createdAt: "2026-05-09T08:00:00Z",
lastSeenAt: "2026-05-10T11:30:00Z",
expiresAt: "2026-06-09T08:00:00Z",
revocable: true,
};
function textFromNode(
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textFromNode).join(" ");
return (node.children ?? []).map(textFromNode).join(" ");
}
function render(): TestRenderer.ReactTestRenderer {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(<ProfileSessions />);
});
return renderer;
}
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function renderAndTrack(): TestRenderer.ReactTestRenderer {
const renderer = render();
mountedRenderers.push(renderer);
return renderer;
}
afterEach(() => {
for (const renderer of mountedRenderers.splice(0)) {
act(() => renderer.unmount());
}
currentResponse = undefined;
deleteAuthSessionMock.mockClear();
mutateMock.mockClear();
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
describe("ProfileSessions", () => {
test("renders profile-style skeletons while loading", () => {
currentResponse = undefined;
const renderer = renderAndTrack();
const text = textFromNode(renderer.toJSON());
// Skeletons render placeholder bars only, no panel headings or content.
expect(text).not.toContain("Browser");
expect(text).not.toContain("CLI");
});
test("splits browser and CLI sessions into separate panels", () => {
currentResponse = { sessions: [cliSession, browserSession] };
const renderer = renderAndTrack();
const text = textFromNode(renderer.toJSON());
expect(text).toContain("Browser");
expect(text).toContain("CLI");
expect(text).toContain("Signed in");
expect(text).toContain("Expires");
expect(text).toContain("Last active");
// Login, provider, user agent, and kind labels are intentionally absent.
expect(text).not.toContain("alice");
expect(text).not.toContain("github");
expect(text).not.toContain("fabro/0.1.0 Darwin");
expect(text).not.toContain("This browser");
expect(text).not.toContain("Fabro CLI");
});
test("shows an empty CLI panel when no CLI sessions exist", () => {
currentResponse = { sessions: [browserSession] };
const renderer = renderAndTrack();
const text = textFromNode(renderer.toJSON());
expect(text).toContain("No CLI sessions.");
const buttons = renderer.root.findAllByType("button");
expect(buttons).toHaveLength(0);
});
test("shows a revoke button for revocable CLI sessions", () => {
currentResponse = { sessions: [cliSession] };
const renderer = renderAndTrack();
const buttons = renderer.root.findAllByType("button");
expect(buttons).toHaveLength(1);
expect(buttons[0].props["aria-label"]).toBe("Revoke CLI session");
});
test("clicking revoke calls the delete endpoint and refreshes the sessions query", async () => {
currentResponse = { sessions: [cliSession] };
const renderer = renderAndTrack();
const button = renderer.root.findByType("button");
await act(async () => {
await button.props.onClick();
});
expect(deleteAuthSessionMock).toHaveBeenCalledTimes(1);
expect(deleteAuthSessionMock.mock.calls[0]?.[0]).toBe("cli:abcd-1234");
expect(mutateMock).toHaveBeenCalledTimes(1);
const mutateKey = mutateMock.mock.calls[0]?.[0] as readonly unknown[];
expect(Array.isArray(mutateKey)).toBe(true);
expect(mutateKey).toEqual(["auth", "sessions"]);
});
});

View file

@ -0,0 +1,150 @@
import { useState } from "react";
import { useSWRConfig } from "swr";
import type { AuthSession } from "@qltysh/fabro-api-client";
import { ApiError, apiData, authApi } from "../lib/api-client";
import { useAuthSessions } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import {
Muted,
Panel,
PanelSkeleton,
Row,
} from "../components/settings-panel";
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
export default function ProfileSessions() {
const { data, error } = useAuthSessions();
const { mutate } = useSWRConfig();
const [revokingId, setRevokingId] = useState<string | null>(null);
const [revokeError, setRevokeError] = useState<string | null>(null);
if (error) {
return (
<div className="space-y-6">
<Panel title="Sessions">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load sessions. Please try again.
</div>
</Panel>
</div>
);
}
if (!data) {
return (
<div className="space-y-6">
<PanelSkeleton />
<PanelSkeleton />
</div>
);
}
const browser = data.sessions.find((s) => s.kind === "browser") ?? null;
const cli = data.sessions
.filter((s) => s.kind === "cli")
.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt));
async function revoke(id: string) {
setRevokeError(null);
setRevokingId(id);
try {
await apiData(() => authApi.deleteAuthSession(id));
await mutate(queryKeys.auth.sessions());
} catch (e) {
const message =
e instanceof ApiError && e.message
? e.message
: "Couldn't revoke this session. Please try again.";
setRevokeError(message);
} finally {
setRevokingId(null);
}
}
return (
<div className="space-y-6">
<Panel title="Browser">
{browser ? (
<>
<Row title="Signed in">
<span title={formatAbsoluteTs(browser.createdAt)}>
{formatRelativeTime(browser.createdAt)}
</span>
</Row>
<Row title="Expires">{formatAbsoluteTs(browser.expiresAt)}</Row>
</>
) : (
<div className="px-4 py-6 text-sm text-fg-muted">
No browser session.
</div>
)}
</Panel>
<Panel title="CLI">
{cli.length === 0 ? (
<div className="px-4 py-6 text-sm text-fg-muted">
No CLI sessions.
</div>
) : (
cli.map((session) => (
<CliRow
key={session.id}
session={session}
onRevoke={revoke}
pending={revokingId === session.id}
disabled={revokingId !== null}
/>
))
)}
</Panel>
{revokeError ? (
<div
role="alert"
className="text-sm text-rose-400"
data-testid="revoke-error"
>
{revokeError}
</div>
) : null}
</div>
);
}
function CliRow({
session,
onRevoke,
pending,
disabled,
}: {
session: AuthSession;
onRevoke: (id: string) => void;
pending: boolean;
disabled: boolean;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3.5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-fg-2">
<span>
<Muted>Last active</Muted>{" "}
<span title={formatAbsoluteTs(session.lastSeenAt)}>
{formatRelativeTime(session.lastSeenAt)}
</span>
</span>
<span>
<Muted>Expires</Muted> {formatAbsoluteTs(session.expiresAt)}
</span>
</div>
<button
type="button"
onClick={() => onRevoke(session.id)}
disabled={disabled}
aria-label="Revoke CLI session"
className="rounded-md border border-line bg-overlay px-2.5 py-1 text-xs text-fg-2 transition-colors hover:bg-overlay-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{pending ? "Revoking…" : "Revoke"}
</button>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { ComputerDesktopIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation } from "react-router";
export function meta({}: any) {
return [{ title: "Profile — Fabro" }];
}
export const handle = { hideHeader: true };
type NavItem = {
name: string;
href: string;
icon: typeof UserCircleIcon;
match: (pathname: string) => boolean;
};
const navItems: NavItem[] = [
{
name: "Profile",
href: "/profile",
icon: UserCircleIcon,
match: (p) => p === "/profile",
},
{
name: "Sessions",
href: "/profile/sessions",
icon: ComputerDesktopIcon,
match: (p) => p.startsWith("/profile/sessions"),
},
];
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export default function ProfileLayout() {
const { pathname } = useLocation();
const currentName = navItems.find((item) => item.match(pathname))?.name ?? "Profile";
return (
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-56 lg:shrink-0">
<nav className="sticky top-6">
<ul role="list" className="flex gap-1 overflow-x-auto lg:flex-col lg:gap-0.5">
{navItems.map((item) => {
const current = item.match(pathname);
return (
<li key={item.name}>
<Link
to={item.href}
aria-current={current ? "page" : undefined}
className={classNames(
"flex items-center gap-2 rounded-md px-2.5 py-2 text-sm whitespace-nowrap transition-colors",
current
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg",
)}
>
<item.icon className="size-4 shrink-0" aria-hidden="true" />
{item.name}
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
<div className="min-w-0 flex-1">
<h1 className="mb-2 text-xl font-semibold tracking-tight text-fg">
{currentName}
</h1>
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline";
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import { formatBytes } from "../lib/format";
import { stageArtifactDownloadUrl } from "../lib/api-client";
import { useRunArtifacts, useRunStages } from "../lib/queries";
import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
export default function RunArtifacts() {
const { id } = useParams();
const stagesQuery = useRunStages(id);
const artifactsQuery = useRunArtifacts(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
return (
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="artifacts" />
<div className="min-w-0 flex-1">{renderBody(id!, artifactsQuery, stages)}</div>
</div>
);
}
function renderBody(
runId: string,
artifactsQuery: ReturnType<typeof useRunArtifacts>,
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
) {
if (artifactsQuery.error) {
return (
<ErrorState
title="Couldn't load artifacts"
description={errorMessage(artifactsQuery.error)}
onRetry={() => void artifactsQuery.mutate()}
/>
);
}
if (artifactsQuery.data === undefined) {
return <LoadingState label="Loading artifacts…" />;
}
const entries = artifactsQuery.data?.data ?? [];
if (entries.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} entries={entries} stages={stages} />;
}
interface StageGroup {
key: string;
stageId: string;
retry: number;
label: string;
entries: RunArtifactEntry[];
totalBytes: number;
}
function groupArtifacts(
entries: readonly RunArtifactEntry[],
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
): StageGroup[] {
const stageLabels = new Map<string, string>();
for (const stage of stages) {
stageLabels.set(stage.id, formatStageLabel(stage));
}
const groups = new Map<string, StageGroup>();
for (const entry of entries) {
const key = `${entry.stage_id}#${entry.retry}`;
const existing = groups.get(key);
if (existing) {
existing.entries.push(entry);
existing.totalBytes += entry.size;
} else {
groups.set(key, {
key,
stageId: entry.stage_id,
retry: entry.retry,
label: stageLabels.get(entry.stage_id) ?? entry.node_slug,
entries: [entry],
totalBytes: entry.size,
});
}
}
for (const group of groups.values()) {
group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path));
}
return [...groups.values()].sort((a, b) => {
const labelCmp = a.label.localeCompare(b.label);
return labelCmp !== 0 ? labelCmp : a.retry - b.retry;
});
}
function ArtifactList({
runId,
entries,
stages,
}: {
runId: string;
entries: readonly RunArtifactEntry[];
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]);
const totalBytes = useMemo(
() => entries.reduce((sum, entry) => sum + entry.size, 0),
[entries],
);
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-medium text-fg">
{entries.length} {entries.length === 1 ? "artifact" : "artifacts"}
</h2>
<span className="text-xs tabular-nums text-fg-muted">
{formatBytes(totalBytes)} total
</span>
</div>
{groups.map((group) => (
<StageGroupCard key={group.key} runId={runId} group={group} />
))}
</div>
);
}
function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) {
return (
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
<header className="flex items-baseline justify-between border-b border-line px-4 py-2.5">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-medium text-fg">{group.label}</h3>
{group.retry > 0 && (
<span className="rounded bg-overlay px-1.5 py-0.5 text-[11px] font-medium text-fg-3">
retry {group.retry}
</span>
)}
</div>
<span className="text-xs tabular-nums text-fg-muted">
{group.entries.length} {group.entries.length === 1 ? "file" : "files"}
{" · "}
{formatBytes(group.totalBytes)}
</span>
</header>
<ul className="divide-y divide-line">
{group.entries.map((entry) => (
<ArtifactRow
key={`${group.key}#${entry.relative_path}`}
runId={runId}
entry={entry}
/>
))}
</ul>
</section>
);
}
function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) {
const [href, setHref] = useState<string>("#");
useEffect(() => {
let active = true;
void stageArtifactDownloadUrl(
runId,
entry.stage_id,
entry.relative_path,
entry.retry,
).then((url) => {
if (active) setHref(url);
});
return () => {
active = false;
};
}, [entry.relative_path, entry.retry, entry.stage_id, runId]);
return (
<li className="flex items-center gap-4 px-4 py-2">
<span
className="flex-1 truncate font-mono text-xs text-fg-2"
title={entry.relative_path}
>
{entry.relative_path}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{formatBytes(entry.size)}
</span>
<a
href={href}
download={basename(entry.relative_path)}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
Download
</a>
</li>
);
}
function basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx >= 0 ? path.slice(idx + 1) : path;
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -66,7 +66,7 @@ describe("RunBilling", () => {
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("renders non-LLM-only stage rows without the by-model section", () => {
test("shows a no-model-usage empty state when every stage is non-billable", () => {
const renderer = renderBilling(
billing({
stages: [
@ -75,12 +75,14 @@ describe("RunBilling", () => {
model: null,
billing: zeroBilling(),
runtime_secs: 0,
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: zeroBilling(),
runtime_secs: 61,
state: "succeeded",
},
],
totals: {
@ -91,12 +93,12 @@ describe("RunBilling", () => {
);
const text = textFromNode(renderer.toJSON());
expect(text).toContain("start");
expect(text).toContain("command");
expect(text).toMatch(/—\s*\/\s*—/);
expect(text).toContain("1m 1s");
expect(text).toContain("No model usage");
expect(text).toContain("This run didn't call any AI models.");
expect(text).not.toContain("No stages yet");
expect(text).not.toContain("By model");
expect(text).not.toContain("No completed stages yet");
expect(text).not.toContain("start");
expect(text).not.toContain("command");
});
test("renders mixed LLM and non-LLM rows while counting only LLM rows by model", () => {
@ -108,6 +110,7 @@ describe("RunBilling", () => {
model: null,
billing: zeroBilling(),
runtime_secs: 0,
state: "succeeded",
},
{
stage: { id: "agent", name: "agent" },
@ -119,6 +122,7 @@ describe("RunBilling", () => {
total_usd_micros: 240000,
}),
runtime_secs: 42,
state: "succeeded",
},
],
totals: {
@ -146,7 +150,7 @@ describe("RunBilling", () => {
);
const text = textFromNode(renderer.toJSON());
expect(text).toContain("start");
expect(text).not.toContain("start");
expect(text).toContain("agent");
expect(text).toContain("By model");
@ -155,11 +159,86 @@ describe("RunBilling", () => {
expect(textFromInstance(byModelFooterCells[1])).toBe("1");
});
test("keeps the empty state for runs with no completed stages", () => {
test("keeps the empty state for runs with no stages", () => {
const renderer = renderBilling(billing());
const text = textFromNode(renderer.toJSON());
expect(text).toContain("No completed stages yet");
expect(text).toContain("Stages will appear once the run produces completed nodes.");
expect(text).toContain("No stages yet");
expect(text).toContain("Stages will appear as soon as the run starts executing.");
});
test("renders an in-flight row with live billing and includes its elapsed time in the footer", () => {
const originalNow = Date.now;
// Pin "now" to 30s after the in-flight row started.
const startedAt = "2026-04-29T12:00:00.000Z";
const fakeNow = new Date("2026-04-29T12:00:30.000Z").getTime();
Date.now = () => fakeNow;
try {
const renderer = renderBilling(
billing({
stages: [
{
stage: { id: "in-flight", name: "in-flight" },
model: { id: "claude-opus-4-6" },
billing: zeroBilling({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
runtime_secs: 0,
started_at: startedAt,
state: "running",
},
],
totals: {
runtime_secs: 0,
...zeroBilling({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
},
by_model: [
{
model: { id: "claude-opus-4-6" },
stages: 1,
billing: zeroBilling({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
},
],
}),
);
const text = textFromNode(renderer.toJSON());
// Empty-state must NOT show — the table should appear as soon as the
// first stage starts.
expect(text).not.toContain("No stages yet");
expect(text).toContain("in-flight");
expect(text).toContain("claude-opus-4-6");
expect(text).toContain("1.2k");
expect(text).toContain("0.3k");
expect(text).toContain("$0.24");
expect(text).toContain("By model");
// Both the row's runtime cell and the footer total should reflect
// ~30s elapsed since started_at.
expect(text).toContain("30s");
const footers = renderer.root.findAll((node) => node.type === "tfoot");
const footerCells = footers[0].findAll((node) => node.type === "td");
// The Run time column in the footer is index 3 (Total / [empty Model] /
// Tokens / Run time / Billing).
const footerRuntime = textFromInstance(footerCells[3]);
expect(footerRuntime).toContain("30s");
} finally {
Date.now = originalNow;
}
});
});

View file

@ -1,7 +1,11 @@
import { useMemo } from "react";
import { EmptyState } from "../components/state";
import { formatDurationSecs } from "../lib/format";
import { useRunBilling } from "../lib/queries";
import type { RunBilling } from "@qltysh/fabro-api-client";
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
import type { RunBilling, RunBillingStage } from "@qltysh/fabro-api-client";
const EMPTY_VALUE = "—";
@ -14,85 +18,128 @@ function formatUsdMicros(usdMicros?: number | null) {
return usdMicros == null ? EMPTY_VALUE : `$${(usdMicros / 1_000_000).toFixed(2)}`;
}
function mapBilling(billing: RunBilling | undefined) {
if (!billing) {
return {
stages: [],
totalRuntime: formatDurationSecs(0),
totalUsdMicros: undefined,
totalInput: null,
totalOutput: null,
modelBreakdown: [],
modelStageCount: 0,
};
}
function isInFlight(stage: RunBillingStage): boolean {
return stage.state != null && IN_FLIGHT_STAGE_STATES.has(stage.state);
}
const stages = billing.stages.map((stage) => {
const hasModel = stage.model != null;
return {
stage: stage.stage.name,
model: stage.model?.id ?? null,
inputTokens: hasModel ? stage.billing.input_tokens : null,
outputTokens: hasModel
? stage.billing.output_tokens + stage.billing.reasoning_tokens
: null,
runtime: formatDurationSecs(stage.runtime_secs),
totalUsdMicros: stage.billing.total_usd_micros,
};
});
const totalRuntime = formatDurationSecs(billing.totals.runtime_secs);
const hasLlmStages = billing.by_model.length > 0;
const totalInput = hasLlmStages ? billing.totals.input_tokens : null;
const totalOutput = hasLlmStages
? billing.totals.output_tokens + billing.totals.reasoning_tokens
: null;
const totalUsdMicros = billing.totals.total_usd_micros;
const modelBreakdown = billing.by_model
.map((entry) => ({
model: entry.model.id,
stages: entry.stages,
inputTokens: entry.billing.input_tokens,
outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens,
totalUsdMicros: entry.billing.total_usd_micros,
}))
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
function isVisibleRow(row: MappedStageRow): boolean {
if (row.inFlight) return true;
return (
(row.inputTokens ?? 0) > 0 ||
(row.outputTokens ?? 0) > 0 ||
(row.totalUsdMicros ?? 0) > 0
);
}
interface MappedStageRow {
stage: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
runtimeSecs: number;
totalUsdMicros: number | null | undefined;
inFlight: boolean;
}
function liveRuntimeSecs(stage: RunBillingStage, now: number): number {
if (stage.started_at) {
const startedMs = new Date(stage.started_at).getTime();
if (Number.isFinite(startedMs)) {
return Math.max(0, (now - startedMs) / 1000);
}
}
return stage.runtime_secs;
}
export const handle = { wide: true };
function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRow {
const hasModel = stage.model != null;
return {
stages,
totalRuntime,
totalUsdMicros,
totalInput,
totalOutput,
modelBreakdown,
modelStageCount,
stage: stage.stage.name,
model: stage.model?.id ?? null,
inputTokens: hasModel ? stage.billing.input_tokens : null,
outputTokens: hasModel
? stage.billing.output_tokens + stage.billing.reasoning_tokens
: null,
runtimeSecs,
totalUsdMicros: stage.billing.total_usd_micros,
inFlight: isInFlight(stage),
};
}
export default function RunBilling({ params }: { params: { id: string } }) {
const billingQuery = useRunBilling(params.id);
const {
stages,
totalRuntime,
totalUsdMicros,
totalInput,
totalOutput,
modelBreakdown,
modelStageCount,
} = mapBilling(billingQuery.data);
const billing = billingQuery.data;
const hasInFlight = billing?.stages.some(isInFlight) ?? false;
if (!stages.length) {
// Tick once per second only while a stage is in-flight.
const now = useTickingNow(hasInFlight);
// Completed rows don't depend on `now`; memoize them by `billing` so we
// don't reallocate them every tick.
const completedRows = useMemo<MappedStageRow[]>(() => {
if (!billing) return [];
return billing.stages.map((stage) => mapStageRow(stage, stage.runtime_secs));
}, [billing]);
// The model breakdown is server-derived and stable across ticks too.
const modelBreakdown = useMemo(() => {
if (!billing) return [];
return billing.by_model
.map((entry) => ({
model: entry.model.id,
stages: entry.stages,
inputTokens: entry.billing.input_tokens,
outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens,
totalUsdMicros: entry.billing.total_usd_micros,
}))
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
}, [billing]);
// Re-derive only the in-flight rows on each tick; everything else stays put.
const rows = useMemo<MappedStageRow[]>(() => {
if (!billing) return [];
if (!hasInFlight) return completedRows;
return billing.stages.map((stage, idx) =>
isInFlight(stage)
? mapStageRow(stage, liveRuntimeSecs(stage, now))
: completedRows[idx],
);
}, [billing, completedRows, hasInFlight, now]);
// While ticking, sum the displayed row runtimes so the footer updates in
// lock-step. Otherwise trust the server's authoritative total.
const totalRuntimeSecs = hasInFlight
? rows.reduce((sum, row) => sum + row.runtimeSecs, 0)
: (billing?.totals.runtime_secs ?? 0);
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null;
const totalOutput = hasLlmStages && billing
? billing.totals.output_tokens + billing.totals.reasoning_tokens
: null;
const totalUsdMicros = billing?.totals.total_usd_micros;
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
const visibleRows = rows.filter(isVisibleRow);
if (!visibleRows.length) {
return (
<div className="py-12">
<EmptyState
title="No completed stages yet"
description="Stages will appear once the run produces completed nodes."
title={rows.length ? "No model usage" : "No stages yet"}
description={
rows.length
? "This run didn't call any AI models."
: "Stages will appear as soon as the run starts executing."
}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="mx-auto max-w-5xl space-y-6">
<div className="overflow-hidden rounded-md border border-line">
<table className="w-full text-sm">
<thead>
@ -105,7 +152,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
</tr>
</thead>
<tbody>
{stages.map((row) => (
{visibleRows.map((row) => (
<tr key={row.stage} className="border-b border-line last:border-b-0">
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
<td className="px-4 py-3 font-mono text-xs text-fg-3">
@ -115,7 +162,9 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">{row.runtime}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatDurationSecs(row.runtimeSecs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicros(row.totalUsdMicros)}
</td>
@ -131,7 +180,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{totalRuntime}
{formatDurationSecs(totalRuntimeSecs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicros(totalUsdMicros)}

View file

@ -1,11 +1,199 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import TestRenderer, { act } from "react-test-renderer";
import {
createMemoryRouter,
RouterProvider,
useParams,
} from "react-router";
import { QuestionType } from "@qltysh/fabro-api-client";
import { ToastProvider } from "../components/toast";
import { DemoModeProvider } from "../lib/demo-mode";
let currentRunSummary: any = null;
let currentRunState: any = null;
let currentQuestions: any[] = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
mock.module("../lib/queries", () => ({
useRun: () => ({
data: currentRunSummary,
isLoading: false,
}),
useRunQuestions: () => ({
data: currentQuestions,
}),
useRunState: () => ({
data: currentRunState,
}),
useRunFiles: () => ({
data: null,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(null)),
}),
}));
mock.module("../lib/run-events", () => ({
useRunEvents: () => undefined,
}));
mock.module("../hooks/use-run-toasts", () => ({
useRunToasts: () => undefined,
}));
const {
actionMenuSeparatorVisibility,
default: RunDetail,
focusSteerAfterMenuClose,
handleLifecycleToastResult,
lifecycleActionVisibility,
type LifecycleToastState,
type RunDetailActionResult,
} from "./run-detail";
} = await import("./run-detail");
mock.restore();
type LifecycleToastState = import("./run-detail").LifecycleToastState;
type RunDetailActionResult = import("./run-detail").RunDetailActionResult;
const h = createElement;
function makeRunSummary(
status = "succeeded",
diffSummary: any = null,
pullRequest: any = null,
title = "Run 1",
) {
const apiStatus =
status === "succeeded"
? { kind: "succeeded", reason: "completed" }
: status === "failed"
? { kind: "failed", reason: "error" }
: status === "dead"
? { kind: "dead" }
: status === "blocked"
? { kind: "blocked", reason: "interview", pending_question_id: null }
: { kind: status };
const archived = status === "archived";
return {
id: "run_1",
goal: "Run 1",
title,
workflow: { slug: "default", name: "Default" },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: archived ? { kind: "succeeded", reason: "completed" } : apiStatus,
pending_control: null,
queue_position: null,
error: null,
archived,
archived_at: archived ? "2026-04-20T12:05:00Z" : null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-04-20T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
billing: null,
diff: diffSummary,
pull_request: pullRequest ? { provider: "github", ...pullRequest } : null,
current_question: null,
superseded_by: null,
links: { web: null },
};
}
function makeQuestion() {
return {
id: "q_1",
text: "Approve?",
stage: "review",
question_type: QuestionType.YES_NO,
options: [],
allow_freeform: false,
timeout_seconds: null,
context_display: null,
};
}
function RunDetailWithParams() {
const params = useParams();
return h(RunDetail, { params: params as { id: string } });
}
async function renderRunDetail({
initialEntry,
status = "succeeded",
questions = [],
diffSummary = null,
pullRequest = null,
title,
}: {
initialEntry: string;
status?: string;
questions?: any[];
diffSummary?: any;
pullRequest?: any;
title?: string;
}) {
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title);
currentQuestions = questions;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const router = createMemoryRouter(
[
{
path: "/runs/:id",
element: h(RunDetailWithParams),
children: [
{
index: true,
element: h("div", { "data-child-route": "overview" }, "Overview"),
},
{
path: "files",
handle: { fullHeight: true },
element: h("div", { "data-child-route": "files" }, "Files"),
},
],
},
],
{ initialEntries: [initialEntry] },
);
let renderer: TestRenderer.ReactTestRenderer | undefined;
await act(async () => {
renderer = TestRenderer.create(
h(
DemoModeProvider,
{ value: false },
h(ToastProvider, null, h(RouterProvider, { router })),
),
);
});
mountedRenderers.push(renderer!);
return renderer!;
}
function hasClasses(value: unknown, classes: string[]) {
const tokens = String(value ?? "").split(/\s+/);
return classes.every((className) => tokens.includes(className));
}
function tabCountBadges(renderer: TestRenderer.ReactTestRenderer) {
return renderer.root.findAll(
(node) => node.type === "span" && hasClasses(node.props.className, ["tabular-nums"]),
);
}
describe("lifecycleActionVisibility", () => {
test("shows cancel for active cancellable states and hides it elsewhere", () => {
@ -31,6 +219,32 @@ describe("lifecycleActionVisibility", () => {
});
});
describe("actionMenuSeparatorVisibility", () => {
test("does not render adjacent dividers when destructive actions follow ops directly", () => {
expect(
actionMenuSeparatorVisibility({
hasLifecycle: false,
hasDestructive: true,
}),
).toEqual({
afterOperations: true,
beforeDestructive: false,
});
});
test("renders both dividers when lifecycle actions sit between ops and destructive actions", () => {
expect(
actionMenuSeparatorVisibility({
hasLifecycle: true,
hasDestructive: true,
}),
).toEqual({
afterOperations: true,
beforeDestructive: true,
});
});
});
describe("handleLifecycleToastResult", () => {
type PushedToast = { message: string; action?: { label: string; onClick: () => void } };
@ -62,12 +276,9 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: {
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("failed"),
};
result.run.lifecycle.status = { kind: "failed", reason: "cancelled" };
const firstState = handleLifecycleToastResult("cancel", result, initialState, api);
@ -86,7 +297,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: { kind: "running" }, created_at: "2026-04-20T12:00:00Z" },
run: makeRunSummary("running"),
};
handleLifecycleToastResult("cancel", result, initialState, api);
@ -99,14 +310,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
run: {
id: "run-1",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("archived"),
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api);
@ -126,11 +330,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "unarchive",
ok: true,
run: {
id: "run-1",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("succeeded"),
};
const stateWithActiveToast: LifecycleToastState = {
activeArchiveToastId: "toast-9",
@ -150,3 +350,198 @@ describe("handleLifecycleToastResult", () => {
expect(replayedState).toBe(nextState);
});
});
describe("RunDetail full-height child routes", () => {
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
currentRunSummary = null;
currentRunState = null;
currentQuestions = [];
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("uses a full-height flex wrapper for fullHeight child routes", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
});
const fullHeightRoot = renderer.root.findAll(
(node) =>
node.type === "div" &&
hasClasses(node.props.className, ["h-full", "min-h-0", "flex", "flex-col"]),
);
expect(fullHeightRoot.length).toBeGreaterThan(0);
const outletWrappers = renderer.root.findAll(
(node) =>
node.type === "div" &&
hasClasses(node.props.className, ["mt-6", "min-h-0", "flex-1"]),
);
expect(outletWrappers).toHaveLength(1);
});
test("shows the Files Changed tab badge from run summary diff stats", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
diffSummary: {
files_changed: 7,
additions: 30,
deletions: 11,
},
});
const badges = tabCountBadges(renderer);
expect(badges.map((badge) => badge.children.join(""))).toContain("7");
});
test("shows the Sandbox tab when the run has a sandbox", async () => {
currentRunState = { sandbox: { provider: "docker", id: "container-1" } };
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
});
const sandboxLinks = renderer.root.findAll(
(node) =>
node.type === "a" &&
node.props.href === "/runs/run_1/sandbox" &&
node.children.includes("Sandbox"),
);
expect(sandboxLinks).toHaveLength(1);
});
test("hides the Sandbox tab when the run has no sandbox", async () => {
currentRunState = {};
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
});
const sandboxLinks = renderer.root.findAll(
(node) =>
node.type === "a" &&
node.props.href === "/runs/run_1/sandbox",
);
expect(sandboxLinks).toHaveLength(0);
});
test("defers steer bar focus until after the Actions menu item click settles", async () => {
const focusCalls: string[] = [];
focusSteerAfterMenuClose(() => focusCalls.push("focus"));
expect(focusCalls).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(focusCalls).toEqual(["focus"]);
});
test("hides the Files Changed tab badge when diff stats are absent", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
});
expect(tabCountBadges(renderer)).toHaveLength(0);
});
test("shows a linked pull request chip in the run header", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
pullRequest: {
html_url: "https://github.com/fabro-sh/fabro/pull/123",
number: 123,
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
});
const links = renderer.root.findAll(
(node) =>
node.type === "a" &&
node.props.href === "https://github.com/fabro-sh/fabro/pull/123",
);
expect(links).toHaveLength(1);
expect(links[0].props.target).toBe("_blank");
expect(links[0].children.filter((child) => typeof child !== "object").join("")).toBe("#123");
});
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
status: "blocked",
questions: [makeQuestion()],
});
const spacers = renderer.root.findAll(
(node) => node.type === "div" && hasClasses(node.props.className, ["h-72"]),
);
expect(spacers).toHaveLength(0);
const dock = renderer.root.findAllByProps({
role: "region",
"aria-label": "Interview question",
});
expect(dock).toHaveLength(1);
const clearanceOwners = renderer.root.findAll(
(node) =>
node.type === "div" &&
node.props.style?.["--fabro-interview-dock-clearance"] === "18rem",
);
expect(clearanceOwners.length).toBeGreaterThan(0);
});
test("renders inline <code> in the run title heading for Markdown-formatted titles", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
title: "Move from `[server.integrations.github]` to `[run.integrations.github]`",
});
const headings = renderer.root.findAll(
(node) =>
node.type === "h2" &&
hasClasses(node.props.className, ["text-xl", "font-semibold", "text-fg"]),
);
expect(headings).toHaveLength(1);
const codes = headings[0]!.findAllByType("code");
expect(codes).toHaveLength(2);
expect(
codes
.map((code) =>
code.children.filter((child) => typeof child === "string").join(""),
),
).toEqual([
"[server.integrations.github]",
"[run.integrations.github]",
]);
});
test("preserves document-flow layout for child routes without fullHeight", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
});
const fullHeightRoot = renderer.root.findAll(
(node) =>
node.type === "div" &&
hasClasses(node.props.className, ["h-full", "min-h-0", "flex", "flex-col"]),
);
expect(fullHeightRoot).toHaveLength(0);
const outletWrappers = renderer.root.findAll(
(node) =>
node.type === "div" &&
hasClasses(node.props.className, [
"mt-6",
"pb-[var(--fabro-interview-dock-clearance)]",
]),
);
expect(outletWrappers).toHaveLength(1);
});
});

View file

@ -1,11 +1,28 @@
import { useEffect, useRef } from "react";
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation } from "react-router";
import {
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import {
ArrowPathIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
FolderIcon,
RectangleStackIcon,
SignalIcon,
} from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { EditableRunTitle } from "../components/editable-run-title";
import { InterviewDock } from "../components/interview-dock";
import { PullRequestChip } from "../components/pull-request-chip";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import { PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS } from "../components/ui";
import { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
import {
isRunStatus,
mapRunSummaryToRunItem,
@ -13,20 +30,28 @@ import {
type RunSummary,
} from "../data/runs";
import { useDemoMode } from "../lib/demo-mode";
import { useSWRConfig } from "swr";
import {
useArchiveRun,
useCancelRun,
useInterruptRun,
usePreviewRun,
useUnarchiveRun,
type LifecycleMutationResult,
type PreviewMutationResult,
} from "../lib/mutations";
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
import { queryKeys } from "../lib/query-keys";
import { useRunEvents } from "../lib/run-events";
import { useRun, useRunQuestions } from "../lib/queries";
import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
import {
canArchive,
canCancel,
canDelete,
canUnarchive,
deleteErrorMessage,
deleteRun,
isTerminalCancelledRun,
mapError,
type LifecycleAction,
@ -37,18 +62,51 @@ const allTabs = [
{ name: "Overview", path: "", count: null, demoOnly: false },
{ name: "Stages", path: "/stages", count: null, demoOnly: false },
{ name: "Files Changed", path: "/files", count: null, demoOnly: false },
{ name: "Graph", path: "/graph", count: null, demoOnly: false },
{ name: "Sandbox", path: "/sandbox", count: null, demoOnly: false, requiresSandbox: true },
{ name: "Billing", path: "/billing", count: null, demoOnly: false },
];
export const handle = { hideHeader: true };
const CANCEL_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg border border-coral/30 bg-coral/10 px-4 py-2 text-sm font-medium text-coral transition-colors hover:bg-coral/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral/10";
export function focusSteerAfterMenuClose(focus: () => void) {
globalThis.setTimeout(focus, 0);
}
const MUTATION_BUTTON_CLASS =
export function actionMenuSeparatorVisibility({
hasLifecycle,
hasDestructive,
}: {
hasLifecycle: boolean;
hasDestructive: boolean;
}) {
return {
afterOperations: hasLifecycle || hasDestructive,
beforeDestructive: hasLifecycle && hasDestructive,
};
}
const ACTIONS_TRIGGER_CLASS =
`${SECONDARY_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-60`;
const MENU_ITEM_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
const MENU_ITEM_DANGER_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
function useTickingNow(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
type RunDetailRun = ReturnType<typeof mapRunSummaryToRunItem> & {
statusLabel: string;
statusDot: string;
@ -74,12 +132,22 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
showPrimaryCancel: canCancel(status),
showArchive: canArchive(status),
showUnarchive: canUnarchive(status),
showDelete: canDelete(status),
};
}
function runHasSandbox(runState: unknown): boolean {
return !!(
runState &&
typeof runState === "object" &&
"sandbox" in runState &&
(runState as { sandbox?: unknown }).sandbox
);
}
function buildRunDetailRun(summary: RunSummary): RunDetailRun {
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status;
const rawStatus = summary.lifecycle.status;
const statusKind = rawStatus.kind;
const display = isRunStatus(statusKind)
? runStatusDisplay[statusKind]
@ -101,22 +169,41 @@ export function meta({ data }: any) {
export default function RunDetail({ params }: { params: { id: string } }) {
const demoMode = useDemoMode();
const runQuery = useRun(params.id);
const runStateQuery = useRunState(params.id);
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
const statusKind = runQuery.data?.status?.kind;
const statusKind = runQuery.data?.lifecycle.status.kind;
const isBlocked = statusKind === "blocked";
const questionsQuery = useRunQuestions(params.id, isBlocked);
const pendingQuestions = questionsQuery.data ?? [];
const { pathname } = useLocation();
const matches = useMatches();
const basePath = `/runs/${params.id}`;
const previewMutation = usePreviewRun(params.id);
const cancelMutation = useCancelRun(params.id);
const archiveMutation = useArchiveRun(params.id);
const unarchiveMutation = useUnarchiveRun(params.id);
const interruptMutation = useInterruptRun(params.id);
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const { push, dismiss } = useToast();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const filesCount = runQuery.data?.diff?.files_changed ?? null;
const hasSandbox = runHasSandbox(runStateQuery.data);
const tabs = allTabs
.map((tab) =>
tab.name === "Files Changed" ? { ...tab, count: filesCount } : tab,
)
.filter((t) => (!t.demoOnly || demoMode) && (!t.requiresSandbox || hasSandbox));
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
const steerBarRef = useRef<SteerBarHandle | null>(null);
const now = useTickingNow(30_000);
const fullHeight = matches.some(
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
);
useRunEvents(params.id);
useRunToasts(params.id);
useEffect(() => {
if (previewMutation.data?.intent === "preview") {
@ -171,101 +258,148 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const cancelPending = cancelMutation.isMutating;
const archivePending = archiveMutation.isMutating;
const unarchivePending = unarchiveMutation.isMutating;
const handleConfirmDelete = async () => {
setDeletePending(true);
try {
await deleteRun(params.id);
void mutate(queryKeys.boards.runs());
void mutate(queryKeys.boards.runs(true));
push({ message: "Run deleted." });
navigate("/runs");
} catch (error) {
push({ message: deleteErrorMessage(error), tone: "error" });
} finally {
setDeletePending(false);
setDeleteDialogOpen(false);
}
};
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
const dockClearance = hasPendingQuestions ? "18rem" : "5rem";
const rootStyle = {
"--fabro-interview-dock-clearance": dockClearance,
} as CSSProperties;
return (
<div>
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
<Link to="/runs" className="text-fg-3 hover:text-fg">Runs</Link>
{demoMode && (
<>
<ChevronRightIcon className="size-3" />
<Link to={`/workflows/${run.workflow}`} className="text-fg-3 hover:text-fg">
{run.workflow}
</Link>
</>
<div
className={fullHeight ? "flex h-full min-h-0 flex-col" : undefined}
style={rootStyle}
>
<nav
className={classNames(
"mb-4 flex items-center gap-1 text-sm text-fg-muted",
fullHeight && "shrink-0",
)}
>
<Link to="/runs" className="text-fg-3 hover:text-fg">Runs</Link>
<ChevronRightIcon className="size-3" />
<Link
to={`/runs?workflow=${encodeURIComponent(run.workflow)}`}
className="text-fg-3 hover:text-fg"
>
{run.workflow}
</Link>
<ChevronRightIcon className="size-3" />
<span>{run.title}</span>
</nav>
<div className="mb-6 flex flex-wrap items-start gap-4">
<div
className={classNames(
"mb-6 flex flex-wrap items-start gap-4",
fullHeight && "shrink-0",
)}
>
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold text-fg">{run.title}</h2>
<div className="mt-2 flex items-center gap-3 text-sm">
<EditableRunTitle runId={params.id} title={run.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${run.statusDot}`} />
<span className={`font-medium ${run.statusText}`}>{run.statusLabel}</span>
</span>
<span className="font-mono text-xs text-fg-muted">{run.repo}</span>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<FolderIcon className="size-3.5" aria-hidden="true" />
{run.repo}
</span>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<RectangleStackIcon className="size-3.5" aria-hidden="true" />
{run.workflow}
</span>
{run.elapsed && (
<span className="font-mono text-xs text-fg-muted">{run.elapsed}</span>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
{run.elapsed}
</span>
)}
{run.lastEventAt && (
<Tooltip label={`Last event ${formatAbsoluteTs(run.lastEventAt)}`}>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<SignalIcon className="size-3.5" aria-hidden="true" />
{formatRelativeTime(run.lastEventAt, now)}
</span>
</Tooltip>
)}
{run.number != null && run.pullRequestUrl && (
<PullRequestChip
number={run.number}
url={run.pullRequestUrl}
iconClassName="size-3.5"
/>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{visibility.showPrimaryCancel && (
<div>
<button
type="button"
onClick={() => void cancelMutation.trigger()}
disabled={cancelPending}
className={CANCEL_BUTTON_CLASS}
>
{cancelPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{cancelPending ? "Cancelling…" : "Cancel"}
</button>
</div>
)}
{demoMode && <ConnectMenu />}
{visibility.showArchive && (
<div>
<button
type="button"
onClick={() => void archiveMutation.trigger()}
disabled={archivePending}
className={MUTATION_BUTTON_CLASS}
>
{archivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{archivePending ? "Archiving…" : "Archive"}
</button>
</div>
)}
{visibility.showUnarchive && (
<div>
<button
type="button"
onClick={() => void unarchiveMutation.trigger()}
disabled={unarchivePending}
className={MUTATION_BUTTON_CLASS}
>
{unarchivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{unarchivePending ? "Restoring…" : "Unarchive"}
</button>
</div>
)}
{run.sandboxId && (
<div>
<button
type="button"
onClick={() => void previewMutation.trigger({
port: 3000,
expires_in_secs: 3600,
})}
disabled={previewPending}
className={PRIMARY_BUTTON_CLASS}
>
{previewPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{previewPending ? "Opening…" : "Preview"}
</button>
</div>
)}
</div>
<ActionsMenu
canSendInterrupt={statusKind === "running"}
interruptPending={interruptMutation.isMutating}
onSendInterrupt={() => void interruptMutation.trigger()}
canFocusSteer={statusKind === "running" && !hasPendingQuestions}
onFocusSteer={() => {
focusSteerAfterMenuClose(() => steerBarRef.current?.focus());
}}
canPreview={hasSandbox}
previewPending={previewPending}
onPreview={() => void previewMutation.trigger({
port: 3000,
expires_in_secs: 3600,
})}
canArchive={visibility.showArchive}
archivePending={archivePending}
onArchive={() => void archiveMutation.trigger()}
canUnarchive={visibility.showUnarchive}
unarchivePending={unarchivePending}
onUnarchive={() => void unarchiveMutation.trigger()}
canDelete={visibility.showDelete}
deletePending={deletePending}
onDelete={() => setDeleteDialogOpen(true)}
canCancel={visibility.showPrimaryCancel}
cancelPending={cancelPending}
onCancel={() => void cancelMutation.trigger()}
/>
</div>
<div className="border-b border-line">
<ConfirmDialog
open={deleteDialogOpen}
title="Delete this run?"
description={
<>
This permanently removes <span className="font-mono text-fg-2">{run.title}</span> and its
durable state. This action cannot be undone.
</>
}
confirmLabel="Delete run"
pendingLabel="Deleting…"
pending={deletePending}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setDeleteDialogOpen(false)}
/>
<div
className={classNames(
"relative before:pointer-events-none before:absolute before:bottom-0 before:left-1/2 before:h-px before:w-screen before:-translate-x-1/2 before:bg-line",
fullHeight && "shrink-0",
)}
>
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => {
const tabPath = `${basePath}${tab.path}`;
@ -296,16 +430,23 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</nav>
</div>
<div className="mt-6">
<div
className={
fullHeight
? "mt-6 flex min-h-0 flex-1 flex-col"
: "mt-6 pb-[var(--fabro-interview-dock-clearance)]"
}
>
<Outlet />
</div>
{isBlocked && pendingQuestions.length > 0 && (
<>
<div aria-hidden="true" className="h-72" />
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-page">
{hasPendingQuestions ? (
<InterviewDock runId={params.id} questions={pendingQuestions} />
</>
)}
) : (
<SteerBar ref={steerBarRef} runId={params.id} />
)}
</div>
</div>
);
}
@ -356,3 +497,178 @@ export function handleLifecycleToastResult(
toastApi.push({ message: "Run restored." });
return { ...nextState, activeArchiveToastId: null };
}
function ConnectMenu() {
return (
<Menu as="div" className="shrink-0">
<MenuButton className={ACTIONS_TRIGGER_CLASS}>
Connect
<ChevronDownIcon className="-mr-1 size-4 text-fg-muted" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 w-44 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<button type="button" className={MENU_ITEM_CLASS}>
Preview
</button>
</MenuItem>
<MenuItem>
<button type="button" className={MENU_ITEM_CLASS}>
SSH
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}
interface ActionsMenuProps {
canSendInterrupt: boolean;
interruptPending: boolean;
onSendInterrupt: () => void;
canFocusSteer: boolean;
onFocusSteer: () => void;
canPreview: boolean;
previewPending: boolean;
onPreview: () => void;
canArchive: boolean;
archivePending: boolean;
onArchive: () => void;
canUnarchive: boolean;
unarchivePending: boolean;
onUnarchive: () => void;
canDelete: boolean;
deletePending: boolean;
onDelete: () => void;
canCancel: boolean;
cancelPending: boolean;
onCancel: () => void;
}
function ActionsMenu(props: ActionsMenuProps) {
const {
canSendInterrupt, interruptPending, onSendInterrupt,
canFocusSteer, onFocusSteer,
canPreview, previewPending, onPreview,
canArchive, archivePending, onArchive,
canUnarchive, unarchivePending, onUnarchive,
canDelete, deletePending, onDelete,
canCancel, cancelPending, onCancel,
} = props;
const hasOps =
canPreview || canSendInterrupt || canFocusSteer;
const hasLifecycle = canArchive || canUnarchive;
const hasDestructive = canCancel || canDelete;
const hasAny = hasOps || hasLifecycle || hasDestructive;
const anyPending =
previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending;
const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive });
if (!hasAny) return null;
return (
<Menu as="div" className="shrink-0">
<MenuButton className={ACTIONS_TRIGGER_CLASS} disabled={anyPending}>
{anyPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
Actions
<ChevronDownIcon className="-mr-1 size-4 text-fg-muted" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 w-44 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
{canPreview && (
<MenuItem>
<button
type="button"
onClick={onPreview}
disabled={previewPending}
className={MENU_ITEM_CLASS}
>
{previewPending ? "Opening…" : "Preview"}
</button>
</MenuItem>
)}
<MenuItem>
<button
type="button"
onClick={onSendInterrupt}
disabled={!canSendInterrupt || interruptPending}
className={MENU_ITEM_CLASS}
>
{interruptPending ? "Interrupting…" : "Send interrupt"}
</button>
</MenuItem>
<MenuItem>
<button
type="button"
onClick={onFocusSteer}
disabled={!canFocusSteer}
className={MENU_ITEM_CLASS}
>
Send steering…
</button>
</MenuItem>
{separators.afterOperations && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canArchive && (
<MenuItem>
<button
type="button"
onClick={onArchive}
disabled={archivePending}
className={MENU_ITEM_CLASS}
>
{archivePending ? "Archiving…" : "Archive"}
</button>
</MenuItem>
)}
{canUnarchive && (
<MenuItem>
<button
type="button"
onClick={onUnarchive}
disabled={unarchivePending}
className={MENU_ITEM_CLASS}
>
{unarchivePending ? "Restoring…" : "Unarchive"}
</button>
</MenuItem>
)}
{separators.beforeDestructive && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canCancel && (
<MenuItem>
<button
type="button"
onClick={onCancel}
disabled={cancelPending}
className={MENU_ITEM_DANGER_CLASS}
>
{cancelPending ? "Cancelling…" : "Cancel"}
</button>
</MenuItem>
)}
{canDelete && (
<MenuItem>
<button
type="button"
onClick={onDelete}
disabled={deletePending}
className={MENU_ITEM_DANGER_CLASS}
>
{deletePending ? "Deleting…" : "Delete"}
</button>
</MenuItem>
)}
</MenuItems>
</Menu>
);
}

View file

@ -0,0 +1,198 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
DebugEventDetailsPanel,
DebugEventRow,
EventSearchInput,
MultiSelectFilter,
debugCategory,
debugCategoryLabel,
} from "../components/event-debug";
import { StageSidebar } from "../components/stage-sidebar";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true, fullHeight: true };
export default function RunEvents() {
const { id } = useParams();
const runQuery = useRun(id);
const stagesQuery = useRunStages(id);
const eventsQuery = useRunEventsList(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
return (
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-6">
<StageSidebar stages={stages} runId={id!} activeLink="events" />
</div>
<div className="relative w-px shrink-0">
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 -bottom-6 bg-line"
/>
</div>
<EventsView
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
/>
</div>
);
}
function EventsView({
events,
error,
onRetry,
runStart,
}: {
events: EventEnvelope[] | undefined;
error: unknown;
onRetry: () => void;
runStart: string | undefined;
}) {
const [openSeq, setOpenSeq] = useState<number | null>(null);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [search, setSearch] = useState("");
const all = events ?? [];
const availableCategories = useMemo<string[]>(() => {
const set = new Set<string>();
for (const event of all) {
if (event.event) set.add(debugCategory(event.event));
}
return Array.from(set).sort();
}, [all]);
const filtered = useMemo<EventEnvelope[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set(selectedCategories);
const needle = search.toLowerCase();
return all.filter((event) => {
const name = event.event ?? "";
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
if (needle) {
const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
if (!blob.includes(needle)) return false;
}
return true;
});
}, [all, selectedCategories, search]);
const openEvent = useMemo<EventEnvelope | null>(
() => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null),
[all, openSeq],
);
const allCategoriesSelected =
selectedCategories.length === 0 ||
selectedCategories.length === availableCategories.length;
const isFiltering = !allCategoriesSelected || search.length > 0;
function clearFilters() {
setSelectedCategories([]);
setSearch("");
}
if (error) {
return (
<div className="min-w-0 flex-1 pt-6">
<ErrorState
title="Couldn't load events"
description={errorMessage(error)}
onRetry={onRetry}
/>
</div>
);
}
if (events === undefined) {
return (
<div className="min-w-0 flex-1 pt-6">
<LoadingState label="Loading events…" />
</div>
);
}
return (
<>
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
<div className="shrink-0 border-b border-line">
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<div className="flex flex-1 flex-wrap items-center gap-2">
<MultiSelectFilter<string>
selected={selectedCategories}
options={availableCategories}
labelOf={debugCategoryLabel}
onChange={setSelectedCategories}
emptyMeansAll
/>
<EventSearchInput value={search} onChange={setSearch} />
{isFiltering && (
<button
type="button"
onClick={clearFilters}
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
Clear
</button>
)}
</div>
{all.length > 0 && (
<span className="text-xs tabular-nums text-fg-muted">
{isFiltering
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events`
: `${all.length.toLocaleString()} events`}
</span>
)}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
{all.length === 0 ? (
<div className="px-2 py-12">
<EmptyState
title="No events yet"
description="Events will appear here as the run executes."
/>
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.
</div>
) : (
filtered.map((event) => (
<DebugEventRow
key={`event-${event.seq}`}
event={event}
runStart={runStart}
selected={openSeq === event.seq}
onSelect={() => setOpenSeq(event.seq)}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenSeq(null)} />
</>
);
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -0,0 +1,306 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { useRef } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter, Route, Routes } from "react-router";
import { ToastProvider } from "../components/toast";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
let currentRunStatus = "succeeded";
const useRunFilesCalls: any[] = [];
const multiFileDiffCalls: any[] = [];
const patchDiffCalls: any[] = [];
let patchDiffMountSeq = 0;
const virtualizerCalls: any[] = [];
const providerCalls: any[] = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
mock.module("@pierre/diffs/react", () => ({
MultiFileDiff: (props: any) => {
multiFileDiffCalls.push(props);
return <div data-pierre-multi="true">{props.newFile.name}</div>;
},
PatchDiff: (props: any) => {
const mountId = useRef(++patchDiffMountSeq);
patchDiffCalls.push({ ...props, mountId: mountId.current });
return (
<div data-pierre-patch="true" data-mount-id={mountId.current}>
{props.patch}
</div>
);
},
Virtualizer: (props: any) => {
virtualizerCalls.push(props);
return <div data-pierre-virtualizer="true">{props.children}</div>;
},
WorkerPoolContextProvider: (props: any) => {
providerCalls.push(props);
return <div data-pierre-worker-pool="true">{props.children}</div>;
},
}));
mock.module("../lib/queries", () => ({
useRun: () => ({
data: {
id: "run_1",
goal: "Run 1",
title: "Run 1",
workflow: { slug: "default", name: "Default" },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: currentRunStatus },
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-05-05T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
},
}),
useRunCommits: () => ({
data: currentCommitsPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentCommitsPayload)),
}),
useRunFiles: (id: string | undefined, selection: any) => {
useRunFilesCalls.push({ id, selection });
return {
data: currentFilesPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentFilesPayload)),
};
},
useRunQuestions: () => ({ data: [] }),
}));
const { default: RunFiles } = await import("./run-files");
function makeFiles(count: number) {
return Array.from({ length: count }, (_, index) => {
const name = `src/file-${index}.ts`;
return {
change_kind: "modified",
old_file: { name, contents: `old ${index}\n` },
new_file: { name, contents: `new ${index}\n` },
};
});
}
function makePayload(count: number, source = "sandbox") {
return {
data: makeFiles(count),
meta: {
source,
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: count,
stats: { additions: count, deletions: count },
truncated: false,
to_sha: "abc1234",
to_sha_committed_at: "2026-05-05T12:00:00Z",
},
};
}
function makePatchPayload(patch: string) {
return {
data: [
{
change_kind: "modified",
old_file: { name: "docs/live.md", contents: null },
new_file: { name: "docs/live.md", contents: null },
unified_patch: patch,
},
],
meta: {
source: "sandbox",
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: 1,
stats: { additions: 1, deletions: 0 },
truncated: false,
to_sha: "abc1234",
to_sha_committed_at: "2026-05-05T12:00:00Z",
},
};
}
function runFilesTree(initialEntry = "/runs/run_1/files") {
return (
<ToastProvider>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/runs/:id/files" element={<RunFiles />} />
</Routes>
</MemoryRouter>
</ToastProvider>
);
}
function renderRunFiles(initialEntry = "/runs/run_1/files") {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | undefined;
act(() => {
renderer = TestRenderer.create(runFilesTree(initialEntry));
});
mountedRenderers.push(renderer!);
return renderer!;
}
describe("RunFiles rendering", () => {
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
currentFilesPayload = null;
currentCommitsPayload = null;
currentRunStatus = "succeeded";
multiFileDiffCalls.length = 0;
patchDiffCalls.length = 0;
patchDiffMountSeq = 0;
virtualizerCalls.length = 0;
providerCalls.length = 0;
useRunFilesCalls.length = 0;
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("renders a one-file payload through Pierre Virtualizer", () => {
currentFilesPayload = makePayload(1);
const renderer = renderRunFiles();
expect(virtualizerCalls).toHaveLength(1);
expect(renderer.root.findAllByProps({ "data-run-file-row": "true" })).toHaveLength(1);
expect(multiFileDiffCalls[0].options.diffStyle).toBe("split");
});
test("passes the selected URL scope to useRunFiles", () => {
currentFilesPayload = makePayload(1);
renderRunFiles("/runs/run_1/files?scope=all#file=src/file-0.ts");
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: { kind: "scope", scope: "all" },
});
});
test("passes a selected commit range to useRunFiles", () => {
currentFilesPayload = makePayload(1);
currentCommitsPayload = {
data: [
{
sha: "b".repeat(40),
short_sha: "bbbbbbb",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "a".repeat(40), short_sha: "aaaaaaa" }],
},
],
};
renderRunFiles(`/runs/run_1/files?commit=${"b".repeat(40)}`);
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: {
kind: "commit",
fromSha: "a".repeat(40),
toSha: "b".repeat(40),
},
});
});
test("shows the scope picker only for sandbox responses", () => {
currentFilesPayload = makePayload(1, "sandbox");
const sandboxRenderer = renderRunFiles();
expect(
sandboxRenderer.root.findAllByProps({ "aria-label": "Diff selection" }),
).not.toHaveLength(0);
act(() => sandboxRenderer.unmount());
mountedRenderers.pop();
currentFilesPayload = makePayload(1, "final_patch");
const fallbackRenderer = renderRunFiles("/runs/run_1/files?scope=all");
expect(
fallbackRenderer.root.findAllByProps({ "aria-label": "Diff selection" }),
).toHaveLength(0);
});
test("renders a 27-file payload through one Pierre Virtualizer", () => {
currentFilesPayload = makePayload(27);
const renderer = renderRunFiles();
expect(virtualizerCalls).toHaveLength(1);
expect(renderer.root.findAllByProps({ "data-run-file-row": "true" })).toHaveLength(27);
});
test("passes stable Pierre cache keys across unrelated re-renders", () => {
currentFilesPayload = makePayload(1);
const renderer = renderRunFiles();
const firstOldKey = multiFileDiffCalls[0].oldFile.cacheKey;
const firstNewKey = multiFileDiffCalls[0].newFile.cacheKey;
act(() => {
renderer.update(runFilesTree());
});
const lastCall = multiFileDiffCalls[multiFileDiffCalls.length - 1];
expect(firstOldKey).toBe(lastCall.oldFile.cacheKey);
expect(firstNewKey).toBe(lastCall.newFile.cacheKey);
expect(firstOldKey).toContain("fabro-run-file:run_1:abc1234:old:src/file-0.ts:");
expect(firstNewKey).toContain("fabro-run-file:run_1:abc1234:new:src/file-0.ts:");
expect(lastCall.options).not.toHaveProperty("theme");
});
test("remounts patch diffs when the patch changes for the same file", () => {
currentFilesPayload = makePatchPayload(
"diff --git a/docs/live.md b/docs/live.md\n@@ -1 +1 @@\n+committed\n",
);
const renderer = renderRunFiles("/runs/run_1/files?scope=all");
const firstMountId = patchDiffCalls[0].mountId;
currentFilesPayload = makePatchPayload(
"diff --git a/docs/live.md b/docs/live.md\n@@ -1,0 +1,2 @@\n+committed\n+uncommitted\n",
);
act(() => {
renderer.update(runFilesTree("/runs/run_1/files?scope=all"));
});
const lastCall = patchDiffCalls[patchDiffCalls.length - 1];
expect(lastCall.patch).toContain("+uncommitted");
expect(lastCall.mountId).not.toBe(firstMountId);
});
});

View file

@ -4,7 +4,12 @@ import {
deepLinkToastMessage,
emptyTransitionToastMessage,
extractRequestId,
normalizeRunFileScope,
} from "./run-files";
import {
buildRunCommitOptions,
fabroGeneratedCommitStage,
} from "./run-files/commit-options";
function buildRunFilesPayload({
files = [],
@ -23,6 +28,8 @@ function buildRunFilesPayload({
meta: {
degraded,
total_changed: files.length,
source: "sandbox",
scope: "committed",
stats: { additions: 0, deletions: 0 },
truncated: false,
},
@ -91,6 +98,57 @@ describe("emptyTransitionToastMessage", () => {
});
});
describe("normalizeRunFileScope", () => {
test("defaults missing or invalid values to committed", () => {
expect(normalizeRunFileScope(null)).toBe("committed");
expect(normalizeRunFileScope("dirty")).toBe("committed");
});
test("accepts supported scope values", () => {
expect(normalizeRunFileScope("committed")).toBe("committed");
expect(normalizeRunFileScope("uncommitted")).toBe("uncommitted");
expect(normalizeRunFileScope("all")).toBe("all");
});
});
describe("buildRunCommitOptions", () => {
test("shortens Fabro-generated subjects to stage visits", () => {
const commits = [
{
sha: "a".repeat(40),
short_sha: "aaaaaaa",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "1".repeat(40), short_sha: "1111111" }],
},
{
sha: "b".repeat(40),
short_sha: "bbbbbbb",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "a".repeat(40), short_sha: "aaaaaaa" }],
},
];
expect(buildRunCommitOptions(commits).map((commit) => commit.label)).toEqual([
"implement@1",
"implement@2",
]);
});
test("leaves externally generated commit subjects intact", () => {
const [option] = buildRunCommitOptions([
{
sha: "a".repeat(40),
short_sha: "aaaaaaa",
subject: "Fix README typo",
parents: [{ sha: "1".repeat(40), short_sha: "1111111" }],
},
]);
expect(fabroGeneratedCommitStage("Fix README typo")).toBeNull();
expect(option.label).toBe("Fix README typo");
});
});
describe("deepLinkToastMessage", () => {
test("returns the missing-file message when the requested file is absent", () => {
expect(

View file

@ -1,15 +1,20 @@
import {
lazy,
memo,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { useParams } from "react-router";
import * as PierreDiffs from "@pierre/diffs/react";
import { useLocation, useNavigate, useParams } from "react-router";
import {
MultiFileDiff,
PatchDiff,
type FileContents,
} from "@pierre/diffs/react";
import { useToast } from "../components/toast";
import type {
FileDiff as ApiFileDiff,
@ -29,26 +34,31 @@ import {
RunFilesErrorBoundary,
} from "./run-files/states";
import { useFileKeyboardNav } from "./run-files/keyboard";
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
import {
Toolbar,
type DiffPickerValue,
type DiffStyle,
} from "./run-files/toolbar";
import { fileCacheKey, stringHash } from "./run-files/cache-keys";
import { buildRunCommitOptions } from "./run-files/commit-options";
import { VirtualizedDiffList } from "./run-files/virtualized-diff-list";
import { ApiError, extractRequestId } from "../lib/api-client";
import { useRun, useRunFiles } from "../lib/queries";
import { useRun, useRunCommits, useRunFiles } from "../lib/queries";
import {
runFileScopeSelection,
type RunFileScope,
type RunFileSelection,
} from "../lib/query-keys";
export { extractRequestId };
const { MultiFileDiff, PatchDiff } = PierreDiffs;
const FileTreeSidebar = lazy(() =>
import("./run-files/file-tree-sidebar").then((module) => ({
default: module.FileTreeSidebar,
})),
);
const maybeVirtualizer = (PierreDiffs as Record<string, unknown>).Virtualizer;
const Virtualizer = typeof maybeVirtualizer === "function"
? maybeVirtualizer as ({ children }: { children: ReactNode }) => ReactElement
: function VirtualizerFallback({ children }: { children: ReactNode }) {
return <>{children}</>;
};
export const handle = { wide: true };
export const handle = { wide: true, fullHeight: true };
const MD_BREAKPOINT_PX = 768;
const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style";
@ -59,6 +69,13 @@ const MIN_REFRESH_SPIN_MS = 500;
export const ErrorBoundary = RunFilesErrorBoundary;
export function normalizeRunFileScope(value: string | null): RunFileScope {
if (value === "all" || value === "uncommitted" || value === "committed") {
return value;
}
return "committed";
}
function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(() => {
if (typeof window === "undefined") return false;
@ -195,13 +212,169 @@ export function deepLinkToastMessage(
return resolveDeepLinkToast(hashFile, data)?.message ?? null;
}
interface RunFileRowProps {
file: ApiFileDiff;
diffStyle: DiffStyle;
isDeepLinkTarget: boolean;
runId: string;
toSha: string | null | undefined;
}
function fileDiffRenderKey({
file,
index,
scope,
toSha,
}: {
file: ApiFileDiff;
index: number;
scope: string;
toSha: string | null | undefined;
}): string {
const display = file.new_file.name || file.old_file.name || `file-${index}`;
const oldContents = file.old_file.contents ?? "";
const newContents = file.new_file.contents ?? "";
const contentFingerprint = file.unified_patch
? `patch:${stringHash(file.unified_patch)}`
: `contents:${stringHash(oldContents)}:${stringHash(newContents)}`;
return [
scope,
toSha ?? "no-sha",
display,
index,
file.change_kind ?? "modified",
contentFingerprint,
].join(":");
}
const RunFileRow = memo(function RunFileRow({
file,
diffStyle,
isDeepLinkTarget,
runId,
toSha,
}: RunFileRowProps) {
const display = file.new_file.name || file.old_file.name;
const placeholder = pickPlaceholder(file);
const oldContents = file.old_file.contents;
const newContents = file.new_file.contents;
const oldPath = file.old_file.name || display;
const newPath = file.new_file.name || display;
const oldFile = useMemo<FileContents | null>(() => {
if (oldContents == null) return null;
return {
...file.old_file,
name: oldPath,
contents: oldContents,
cacheKey: fileCacheKey({
runId,
toSha,
side: "old",
path: oldPath,
contents: oldContents,
}),
};
}, [file.old_file, oldContents, oldPath, runId, toSha]);
const newFile = useMemo<FileContents | null>(() => {
if (newContents == null) return null;
return {
...file.new_file,
name: newPath,
contents: newContents,
cacheKey: fileCacheKey({
runId,
toSha,
side: "new",
path: newPath,
contents: newContents,
}),
};
}, [file.new_file, newContents, newPath, runId, toSha]);
const multiFileOptions = useMemo(
() => ({
diffStyle,
expandUnchanged: isDeepLinkTarget ? true : undefined,
}),
[diffStyle, isDeepLinkTarget],
);
const patchOptions = useMemo(() => ({ diffStyle }), [diffStyle]);
const patch = useMemo(() => file.unified_patch ?? null, [file.unified_patch]);
let body: ReactElement | null = null;
if (placeholder) {
body = placeholder;
} else if (oldFile && newFile) {
body = (
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
options={multiFileOptions}
/>
);
} else if (patch) {
body = (
<PatchDiff
key={stringHash(patch)}
patch={patch}
options={patchOptions}
/>
);
}
return (
<div
id={fileRowId(display)}
tabIndex={-1}
data-run-file-row="true"
role="region"
aria-label={`${file.change_kind ?? "modified"}: ${display}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
{body}
</div>
);
});
export default function RunFiles() {
const params = useParams();
const filesQuery = useRunFiles(params.id);
const location = useLocation();
const navigate = useNavigate();
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search],
);
const selectedScope = normalizeRunFileScope(searchParams.get("scope"));
const selectedCommitSha = searchParams.get("commit");
const commitsQuery = useRunCommits(params.id);
const commitOptions = useMemo(
() => buildRunCommitOptions(commitsQuery.data?.data ?? []),
[commitsQuery.data],
);
const selectedCommit = selectedCommitSha
? commitOptions.find((commit) => commit.sha === selectedCommitSha)
: undefined;
const waitingForCommitSelection =
!!selectedCommitSha && commitsQuery.data === undefined && !commitsQuery.error;
const fileSelection: RunFileSelection =
selectedCommit && selectedCommit.fromSha
? {
kind: "commit",
fromSha: selectedCommit.fromSha,
toSha: selectedCommit.toSha,
}
: runFileScopeSelection(selectedScope);
const filesQuery = useRunFiles(
waitingForCommitSelection ? undefined : params.id,
fileSelection,
);
const runQuery = useRun(params.id);
const { push } = useToast();
const narrow = useNarrowViewport();
const runStatus = runQuery.data?.status?.kind;
const runStatus = runQuery.data?.lifecycle.status.kind;
// Preserve the last successful payload so a failed revalidation can keep
// rendering the previous files while surfacing an inline banner.
@ -224,7 +397,7 @@ export default function RunFiles() {
const data: PaginatedRunFileList | null =
filesQuery.data ?? lastGoodDataRef.current;
const isInitialLoading = filesQuery.isLoading && !data;
const isInitialLoading = (waitingForCommitSelection || filesQuery.isLoading) && !data;
const isRevalidating = filesQuery.isValidating;
// Revalidation error is whatever the most recent loader call returned;
@ -265,6 +438,24 @@ export default function RunFiles() {
setMinSpinUntil(Date.now() + MIN_REFRESH_SPIN_MS);
void filesQuery.mutate();
}, [filesQuery]);
const handlePickerChange = useCallback(
(selection: DiffPickerValue) => {
const search = new URLSearchParams(location.search);
if (selection.kind === "commit") {
search.set("commit", selection.sha);
search.delete("scope");
} else {
search.set("scope", selection.scope);
search.delete("commit");
}
navigate({
pathname: location.pathname,
search: `?${search.toString()}`,
hash: location.hash,
});
},
[location.hash, location.pathname, location.search, navigate],
);
useEffect(() => {
if (minSpinUntil === 0) return;
const remaining = minSpinUntil - Date.now();
@ -292,7 +483,7 @@ export default function RunFiles() {
// Deep-link handling: scroll + focus the matching row. Expansion is
// handled by passing `expandUnchanged: true` to the targeted MultiFileDiff
// via per-file options (see `renderFiles` below) — @pierre/diffs 1.1.x
// via per-file options on `RunFileRow` — @pierre/diffs 1.1.x
// exposes no imperative expand API, so click-based "expand" is not
// available.
const [hashFile, setHashFile] = useState<string | null>(() => {
@ -332,76 +523,6 @@ export default function RunFiles() {
window.location.hash = next;
}, []);
const renderFiles = useCallback(
(files: ApiFileDiff[]): ReactElement[] =>
files.map((file, idx) => {
const display = file.new_file.name || file.old_file.name;
const placeholder = pickPlaceholder(file);
if (placeholder) {
return (
<div
key={`${display}-${idx}`}
id={fileRowId(display)}
tabIndex={-1}
data-run-file-row="true"
role="region"
aria-label={`${file.change_kind ?? "changed"}: ${display}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
{placeholder}
</div>
);
}
// When the deep-link targets this file, pass expandUnchanged:true so
// the full surrounding context renders without per-hunk clicking.
const isDeepLinkTarget =
!!hashFile &&
(file.new_file.name === hashFile || file.old_file.name === hashFile);
const oldContents = file.old_file.contents;
const newContents = file.new_file.contents;
let body: ReactElement | null = null;
if (oldContents != null && newContents != null) {
const oldFile = { ...file.old_file, contents: oldContents };
const newFile = { ...file.new_file, contents: newContents };
body = (
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
options={{
diffStyle,
theme: "pierre-dark",
expandUnchanged: isDeepLinkTarget ? true : undefined,
}}
/>
);
} else if (file.unified_patch) {
body = (
<PatchDiff
patch={file.unified_patch}
options={{
diffStyle,
theme: "pierre-dark",
}}
/>
);
}
return (
<div
key={`${file.new_file.name}-${idx}`}
id={fileRowId(display)}
tabIndex={-1}
data-run-file-row="true"
role="region"
aria-label={`${file.change_kind ?? "modified"}: ${file.new_file.name}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
{body}
</div>
);
}),
[diffStyle, hashFile],
);
if (isInitialLoading) {
return <LoadingSkeleton reserveSidebar={!narrow} />;
}
@ -430,6 +551,14 @@ export default function RunFiles() {
}
const { data: files, meta } = data;
const showScopePicker = data.meta.source === "sandbox";
const pickerSelection: DiffPickerValue =
selectedCommit && selectedCommit.fromSha
? { kind: "commit", sha: selectedCommit.sha }
: { kind: "scope", scope: showScopePicker ? selectedScope : "committed" };
const effectiveScope = fileSelection.kind === "commit"
? `commit:${fileSelection.toSha}`
: fileSelection.scope;
// Refresh is disabled when the server reports the same `to_sha` it
// reported on the previous successful fetch — no new checkpoint yet.
@ -446,6 +575,10 @@ export default function RunFiles() {
additions: meta.stats.additions,
deletions: meta.stats.deletions,
}}
selection={pickerSelection}
commits={commitOptions}
showScopePicker={showScopePicker}
onPickerChange={handlePickerChange}
onRefresh={handleRefresh}
refreshing={showRefreshing}
refreshDisabled={refreshDisabled}
@ -459,7 +592,7 @@ export default function RunFiles() {
if (files.length === 0) {
return (
<div ref={containerRef} className="flex flex-col gap-4">
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col gap-4">
{toolbar}
{meta.degraded ? (
<DegradedBanner reason={meta.degraded_reason} />
@ -475,28 +608,21 @@ export default function RunFiles() {
);
}
// Large result sets get @pierre/diffs Virtualizer for lazy mounting so
// 200-file runs don't synchronously mount every diff.
const body =
files.length > 20 ? (
<Virtualizer>{renderFiles(files)}</Virtualizer>
) : (
<>{renderFiles(files)}</>
);
return (
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
{revalidationError ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => void filesQuery.mutate()}
/>
) : null}
{meta.degraded ? (
<DegradedBanner reason={meta.degraded_reason} />
) : null}
<div className="flex gap-4">
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="shrink-0 space-y-4">
{toolbar}
{revalidationError ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => void filesQuery.mutate()}
/>
) : null}
{meta.degraded ? (
<DegradedBanner reason={meta.degraded_reason} />
) : null}
</div>
<div className="flex min-h-0 flex-1 gap-4">
{!narrow ? (
<Suspense fallback={<FileTreeSidebarSkeleton />}>
<FileTreeSidebar
@ -506,8 +632,30 @@ export default function RunFiles() {
/>
</Suspense>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-4">
{body}
<div className="flex min-w-0 min-h-0 flex-1 flex-col">
<VirtualizedDiffList>
{files.map((file, idx) => {
const display = file.new_file.name || file.old_file.name;
const isDeepLinkTarget =
!!hashFile &&
(file.new_file.name === hashFile || file.old_file.name === hashFile);
return (
<RunFileRow
key={fileDiffRenderKey({
file,
index: idx,
scope: effectiveScope,
toSha: meta.to_sha,
})}
file={file}
diffStyle={diffStyle}
isDeepLinkTarget={isDeepLinkTarget}
runId={params.id ?? "unknown-run"}
toSha={meta.to_sha}
/>
);
})}
</VirtualizedDiffList>
</div>
</div>
</div>

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { fileCacheKey } from "./cache-keys";
describe("fileCacheKey", () => {
test("identical inputs produce the same key", () => {
const first = fileCacheKey({
runId: "run_1",
toSha: "abc1234",
side: "old",
path: "src/app.ts",
contents: "export const value = 1;\n",
});
const second = fileCacheKey({
runId: "run_1",
toSha: "abc1234",
side: "old",
path: "src/app.ts",
contents: "export const value = 1;\n",
});
expect(second).toBe(first);
});
test("changing file contents changes the key", () => {
const base = {
runId: "run_1",
toSha: "abc1234",
side: "new" as const,
path: "src/app.ts",
};
expect(fileCacheKey({ ...base, contents: "one" })).not.toBe(
fileCacheKey({ ...base, contents: "two" }),
);
});
test("changing file name or side changes the key", () => {
const base = {
runId: "run_1",
toSha: "abc1234",
contents: "same",
};
expect(fileCacheKey({ ...base, side: "old", path: "src/a.ts" })).not.toBe(
fileCacheKey({ ...base, side: "old", path: "src/b.ts" }),
);
expect(fileCacheKey({ ...base, side: "old", path: "src/a.ts" })).not.toBe(
fileCacheKey({ ...base, side: "new", path: "src/a.ts" }),
);
});
test("missing toSha still produces a deterministic key", () => {
expect(
fileCacheKey({
runId: "run_1",
toSha: null,
side: "new",
path: "src/app.ts",
contents: "same",
}),
).toMatch(/^fabro-run-file:run_1:no-sha:new:src\/app\.ts:[0-9a-f]{8}$/);
});
});

View file

@ -0,0 +1,26 @@
export type FileCacheSide = "old" | "new";
export function stringHash(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
export function fileCacheKey({
runId,
toSha,
side,
path,
contents,
}: {
runId: string;
toSha: string | null | undefined;
side: FileCacheSide;
path: string;
contents: string;
}): string {
return `fabro-run-file:${runId}:${toSha ?? "no-sha"}:${side}:${path}:${stringHash(contents)}`;
}

View file

@ -0,0 +1,37 @@
import type { RunCommit } from "@qltysh/fabro-api-client";
export type RunCommitPickerOption = {
sha: string;
label: string;
title: string;
fromSha: string | null;
toSha: string;
};
export function fabroGeneratedCommitStage(subject: string): string | null {
const match = subject.match(/^fabro\([^)]+\):\s+(.+?)\s+\([^)]+\)$/);
const stage = match?.[1]?.trim();
return stage ? stage : null;
}
export function buildRunCommitOptions(
commits: Pick<RunCommit, "sha" | "short_sha" | "subject" | "parents">[],
): RunCommitPickerOption[] {
const generatedVisits = new Map<string, number>();
return commits.map((commit) => {
const stage = fabroGeneratedCommitStage(commit.subject);
let label = commit.subject || commit.short_sha;
if (stage) {
const visit = (generatedVisits.get(stage) ?? 0) + 1;
generatedVisits.set(stage, visit);
label = `${stage}@${visit}`;
}
return {
sha: commit.sha,
fromSha: commit.parents[0]?.sha ?? null,
toSha: commit.sha,
label,
title: `${commit.short_sha} ${commit.subject}`.trim(),
};
});
}

View file

@ -2,8 +2,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} from "react";
import {
@ -80,25 +78,16 @@ export function FileTreeSidebar({
selectedPath,
onSelect,
}: FileTreeSidebarProps) {
const [filterText, setFilterText] = useState("");
const normalizedFilter = filterText.trim().toLowerCase();
const filteredFiles = useMemo(
() =>
normalizedFilter
? files.filter((file) => filePath(file).toLowerCase().includes(normalizedFilter))
: files,
[files, normalizedFilter],
);
const paths = useMemo(() => filteredFiles.map(filePath), [filteredFiles]);
const paths = useMemo(() => files.map(filePath), [files]);
const changedPaths = useMemo(() => new Set(paths), [paths]);
const gitStatus = useMemo<GitStatusEntry[]>(
() =>
filteredFiles.map((file) => ({
files.map((file) => ({
path: filePath(file),
status: gitStatusFor(file),
})),
[filteredFiles],
[files],
);
const onSelectRef = useRef(onSelect);
@ -161,11 +150,13 @@ export function FileTreeSidebar({
);
}, [changedPaths, model, selectedPath, selection]);
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) =>
setFilterText(event.target.value);
const themeStyles = useMemo(
() => themeToTreeStyles(pierreDark) as TreeThemeStyle,
const themeStyles = useMemo<TreeThemeStyle>(
() => ({
...(themeToTreeStyles(pierreDark) as TreeThemeStyle),
backgroundColor: "transparent",
"--trees-bg-override": "transparent",
"--trees-padding-inline-override": "0px",
}),
[],
);
@ -173,21 +164,13 @@ export function FileTreeSidebar({
<aside
aria-label="Changed files"
style={themeStyles}
className="sticky top-4 flex h-[calc(100vh-6rem)] w-72 shrink-0 flex-col gap-2 self-start"
className="-ml-0.5 flex min-h-0 w-72 shrink-0 flex-col self-stretch"
>
<input
type="search"
value={filterText}
onChange={handleSearchChange}
placeholder="Filter changed files…"
aria-label="Filter changed files"
className="w-full rounded-md border border-line bg-panel px-2 py-1.5 text-sm text-fg placeholder:text-fg-muted focus:outline-2 focus:outline-focus focus:outline-offset-2"
/>
{paths.length > 0 ? (
<FileTree model={model} className="min-h-0 flex-1 overflow-hidden" />
) : (
<div role="status" className="min-h-0 flex-1 px-3 py-2 text-sm text-fg-muted">
No matching files
No changed files
</div>
)}
</aside>

View file

@ -1,20 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { ReactNode } from "react";
import * as PierreDiffs from "@pierre/diffs/react";
const { MultiFileDiff, PatchDiff } = PierreDiffs;
const maybeVirtualizer = (PierreDiffs as Record<string, unknown>).Virtualizer;
const Virtualizer = typeof maybeVirtualizer === "function"
? maybeVirtualizer
: function VirtualizerFallback({ children }: { children: ReactNode }) {
return <>{children}</>;
};
// Regression coverage for the @pierre/diffs 1.0 -> 1.1 upgrade. We assert
// only that the React components the Run Files route consumes remain
// callable. `Virtualizer` is optional across installed pierre versions, so
// the route carries a no-op fallback and the smoke test mirrors that
// compatibility layer rather than assuming a specific package export set.
const {
MultiFileDiff,
PatchDiff,
Virtualizer,
WorkerPoolContextProvider,
} = PierreDiffs;
describe("@pierre/diffs public API", () => {
test("MultiFileDiff is a callable component export", () => {
@ -28,4 +20,8 @@ describe("@pierre/diffs public API", () => {
test("Virtualizer is a callable component export", () => {
expect(typeof Virtualizer).toBe("function");
});
test("WorkerPoolContextProvider is a callable component export", () => {
expect(typeof WorkerPoolContextProvider).toBe("function");
});
});

View file

@ -93,13 +93,14 @@ describe("pickPlaceholder priority", () => {
});
describe("bannerCopyForReason", () => {
test("each known reason gets distinct copy", () => {
const a = bannerCopyForReason("sandbox_gone");
test("sandbox_gone is suppressed", () => {
expect(bannerCopyForReason("sandbox_gone")).toBeNull();
});
test("provider_unsupported and sandbox_unreachable get distinct copy", () => {
const b = bannerCopyForReason("provider_unsupported");
const c = bannerCopyForReason("sandbox_unreachable");
expect(a).not.toBe(b);
expect(b).not.toBe(c);
expect(a).toContain("cleaned up");
expect(b).toContain("provider");
expect(c).toContain("refresh");
});
@ -146,9 +147,13 @@ describe("placeholder rendering", () => {
).toContain("submodule");
});
test("DegradedBanner renders nothing for sandbox_gone", () => {
expect(renderedText(<DegradedBanner reason="sandbox_gone" />)).toBe("");
});
test("DegradedBanner picks copy based on reason", () => {
expect(renderedText(<DegradedBanner reason="sandbox_gone" />)).toContain(
"cleaned up",
);
expect(
renderedText(<DegradedBanner reason="provider_unsupported" />),
).toContain("provider");
});
});

View file

@ -103,19 +103,21 @@ export function DegradedBanner({
}: {
reason?: RunFilesMetaDegradedReasonEnum;
}) {
const copy = bannerCopyForReason(reason);
if (copy === null) return null;
return (
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
{bannerCopyForReason(reason)}
{copy}
</div>
);
}
export function bannerCopyForReason(
reason: RunFilesMetaDegradedReasonEnum | undefined | string,
): string {
): string | null {
switch (reason) {
case "sandbox_gone":
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
return null;
case "provider_unsupported":
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
case "sandbox_unreachable":

Some files were not shown because too many files have changed in this diff Show more