From e383f6fec4b40f2c120211227c52d02afb889069 Mon Sep 17 00:00:00 2001 From: zackees Date: Mon, 27 Jul 2026 17:30:06 -0700 Subject: [PATCH] feat(ide): daemon-served Build Progress page + fbuild build-progress (#1076 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /build-progress serves a self-contained embedded page (plotter pattern): status header polling /api/daemon/info (state, current operation, dependency install), activity pane attached to the existing /ws/logs BroadcastHub fan-out, autoscroll/clear, light+dark, zero external assets. Consumes only existing endpoints — per-request NDJSON build output remains initiator-owned. - New `fbuild build-progress` opener command; `fbuild ide` emits a "fbuild: Build Progress" task running it. Part of #1076 (Phase 2, second panel). Co-Authored-By: Claude Fable 5 --- agents/docs/commands-reference.md | 1 + crates/fbuild-cli/src/cli/args.rs | 7 + crates/fbuild-cli/src/cli/build_progress.rs | 57 +++ crates/fbuild-cli/src/cli/dispatch.rs | 2 + crates/fbuild-cli/src/cli/ide.rs | 14 +- crates/fbuild-cli/src/cli/mod.rs | 1 + crates/fbuild-cli/src/cli/tests.rs | 8 + .../src/handlers/build_progress.rs | 91 ++++ crates/fbuild-daemon/src/handlers/mod.rs | 1 + crates/fbuild-daemon/src/main.rs | 3 +- .../tests/test_build_progress_route.rs | 68 +++ .../web/build-progress/README.md | 25 ++ .../web/build-progress/index.html | 388 ++++++++++++++++++ docs/reference/cli.md | 39 +- 14 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 crates/fbuild-cli/src/cli/build_progress.rs create mode 100644 crates/fbuild-daemon/src/handlers/build_progress.rs create mode 100644 crates/fbuild-daemon/tests/test_build_progress_route.rs create mode 100644 crates/fbuild-daemon/web/build-progress/README.md create mode 100644 crates/fbuild-daemon/web/build-progress/index.html diff --git a/agents/docs/commands-reference.md b/agents/docs/commands-reference.md index 0ada1425..ef62fd8b 100644 --- a/agents/docs/commands-reference.md +++ b/agents/docs/commands-reference.md @@ -38,6 +38,7 @@ help text). | `fbuild ide` / `fbuild ide select` | You want to open a project as an IDE workspace on stock Zed: installs declared deps, refreshes the compile DB, emits `.clangd` + `.zed/settings.json` + `.zed/tasks.json`, and — for probe-rs-supported boards only (RP2040/RP2350, a small ARM Cortex-M set) — `.zed/debug.json` plus a `probe-rs dap-server` task, then launches Zed. Unsupported targets (ESP32, AVR) get a one-line "not supported" note, not a failure. `ide select` interactively (or via `-e`) switches the persisted environment and regenerates. | `fbuild help ide`, FastLED/fbuild#1076 Phase 1 & Phase 3 milestone 1, `docs/reference/cli.md#fbuild-ide` | | `fbuild lib-select` | Drive the LDF-style library-selection resolver and print the selected library set. Use this when debugging "library not found" without a full build. | FastLED/fbuild#202 / #204 | | `fbuild plotter [-p ]` | Open the daemon-served Serial Plotter web page (`GET /plotter`) in the default browser: a self-contained, dependency-free `` chart over the existing `/ws/serial-monitor` WebSocket, port list from `/api/devices/list`. `fbuild ide` wires this up as the `"fbuild: Serial Plotter"` Zed task. | `fbuild help plotter`, FastLED/fbuild#1076 Phase 2, `docs/reference/cli.md#fbuild-plotter` | +| `fbuild build-progress` | Open the daemon-served Build Progress web page (`GET /build-progress`) in the default browser: status polled from the existing `/api/daemon/info` every ~2s plus a live activity tail over the existing `/ws/logs` broadcast WebSocket — no new daemon endpoints. `fbuild ide` wires this up as the `"fbuild: Build Progress"` Zed task. | `fbuild help build-progress`, FastLED/fbuild#1076 Phase 2, `docs/reference/cli.md#fbuild-build-progress` | ## Daemon & cache diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs index 4adfb075..83758c0f 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -534,6 +534,12 @@ pub enum Commands { #[arg(short = 'p', long)] port: Option, }, + /// Open the daemon-served Build Progress web page in the default + /// browser (FastLED/fbuild#1076 Phase 2, second panel): daemon + /// state/current-operation polled from `/api/daemon/info` every ~2s + /// plus a live activity tail over the existing `/ws/logs` broadcast + /// websocket + BuildProgress, /// Build firmware and run it in an emulator for testing TestEmu { project_dir: Option, @@ -1008,6 +1014,7 @@ pub const KNOWN_SUBCOMMANDS: &[&str] = &[ "clangd-config", "ide", "plotter", + "build-progress", "clang-query", "test-emu", "lib-select", diff --git a/crates/fbuild-cli/src/cli/build_progress.rs b/crates/fbuild-cli/src/cli/build_progress.rs new file mode 100644 index 00000000..0ba347d3 --- /dev/null +++ b/crates/fbuild-cli/src/cli/build_progress.rs @@ -0,0 +1,57 @@ +//! `fbuild build-progress`: open the daemon-served Build Progress web page +//! (FastLED/fbuild#1076 Phase 2, second panel) in the default browser. +//! +//! The page itself is a self-contained page served by fbuild-daemon at +//! `GET /build-progress` that polls the existing `/api/daemon/info` +//! endpoint for status and attaches to the existing `/ws/logs` broadcast +//! WebSocket for a live activity tail +//! (`crates/fbuild-daemon/web/build-progress/index.html`). So this +//! command's only job is: make sure the daemon is running, build the URL, +//! and hand it to the OS's default browser via +//! [`super::build::open_in_browser`] — the same helper `fbuild plotter` +//! uses. +//! +//! Deliberately a standalone tiny command (mirrors `cli::plotter`) rather +//! than an OS-specific "open a URL" invocation baked into +//! `.zed/tasks.json`: the generated Zed task (`fbuild ide`'s +//! `build_fbuild_tasks`) just runs `fbuild build-progress`, and the same +//! command works for anyone not using Zed. + +use crate::daemon_client; +use crate::output; + +use super::build::open_in_browser; + +/// Build the `/build-progress` URL for the daemon at `base_url`. Pure — +/// no I/O — so it's directly testable without a running daemon. +pub fn build_progress_url(base_url: &str) -> String { + format!("{base_url}/build-progress") +} + +/// `fbuild build-progress` +pub async fn run_build_progress() -> fbuild_core::Result<()> { + daemon_client::ensure_daemon_running().await?; + let base_url = fbuild_paths::get_daemon_url(); + let url = build_progress_url(&base_url); + + output::progress(format!("Opening Build Progress: {}", url)); + if let Err(e) = open_in_browser(&url).await { + output::warn(format!("failed to open browser: {}", e)); + output::warn(format!("open this URL manually: {}", url)); + } + output::result(url); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_progress_url_appends_path() { + assert_eq!( + build_progress_url("http://127.0.0.1:49200"), + "http://127.0.0.1:49200/build-progress" + ); + } +} diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs index afb00487..6c0775bf 100644 --- a/crates/fbuild-cli/src/cli/dispatch.rs +++ b/crates/fbuild-cli/src/cli/dispatch.rs @@ -10,6 +10,7 @@ use super::args::{BloatCmd, Cli, Commands, IdeAction, resolve_project_dir, rewri use super::bloat_lookup::run_bloat_lookup; use super::bringup::run_bringup; use super::build::run_build; +use super::build_progress::run_build_progress; use super::cache::run_cache; use super::clang_tools::{run_clang_tool, run_iwyu}; use super::clangd_config::run_clangd_config; @@ -481,6 +482,7 @@ pub async fn async_main() { } }, Some(Commands::Plotter { port }) => run_plotter(port).await, + Some(Commands::BuildProgress) => run_build_progress().await, Some(Commands::TestEmu { project_dir, environment, diff --git a/crates/fbuild-cli/src/cli/ide.rs b/crates/fbuild-cli/src/cli/ide.rs index e2c15622..7a35bcf5 100644 --- a/crates/fbuild-cli/src/cli/ide.rs +++ b/crates/fbuild-cli/src/cli/ide.rs @@ -159,6 +159,10 @@ fn build_fbuild_tasks(env_name: &str, debug_chip: Option<&str>) -> Vec // /api/devices/list) is how the user picks a port, so this task // works regardless of which environment/port is active. task("Serial Plotter", "fbuild", str_args(vec!["plotter"])), + // Opens the daemon-served Build Progress page (FastLED/fbuild#1076 + // Phase 2, second panel): status polled from /api/daemon/info, + // activity tail over the existing /ws/logs broadcast websocket. + task("Build Progress", "fbuild", str_args(vec!["build-progress"])), task( "Select environment", "fbuild", @@ -689,7 +693,7 @@ mod tests { #[test] fn build_fbuild_tasks_pins_environment_in_args() { let tasks = build_fbuild_tasks("esp32dev", None); - assert_eq!(tasks.len(), 8); + assert_eq!(tasks.len(), 9); for label in [ "fbuild: Build", "fbuild: Build (clean)", @@ -698,6 +702,7 @@ mod tests { "fbuild: Monitor", "fbuild: Reset", "fbuild: Serial Plotter", + "fbuild: Build Progress", "fbuild: Select environment", ] { assert!( @@ -717,6 +722,11 @@ mod tests { .find(|t| t.label == "fbuild: Serial Plotter") .unwrap(); assert_eq!(plotter.args, vec!["plotter"]); + let build_progress = tasks + .iter() + .find(|t| t.label == "fbuild: Build Progress") + .unwrap(); + assert_eq!(build_progress.args, vec!["build-progress"]); // No debug-chip resolved -> no debug-server task. assert!(!tasks.iter().any(|t| t.label.contains("Debug server"))); } @@ -724,7 +734,7 @@ mod tests { #[test] fn build_fbuild_tasks_adds_debug_server_task_when_chip_resolved() { let tasks = build_fbuild_tasks("rpipico", Some("RP2040")); - assert_eq!(tasks.len(), 9); + assert_eq!(tasks.len(), 10); let debug = tasks .iter() .find(|t| t.label == "fbuild: Debug server (probe-rs)") diff --git a/crates/fbuild-cli/src/cli/mod.rs b/crates/fbuild-cli/src/cli/mod.rs index 52767ad7..a6e91efb 100644 --- a/crates/fbuild-cli/src/cli/mod.rs +++ b/crates/fbuild-cli/src/cli/mod.rs @@ -13,6 +13,7 @@ pub mod args; pub mod bloat_lookup; pub mod bringup; pub mod build; +pub mod build_progress; pub mod cache; pub mod clang_tools; pub mod clangd_config; diff --git a/crates/fbuild-cli/src/cli/tests.rs b/crates/fbuild-cli/src/cli/tests.rs index 802f2884..c2bda115 100644 --- a/crates/fbuild-cli/src/cli/tests.rs +++ b/crates/fbuild-cli/src/cli/tests.rs @@ -92,6 +92,14 @@ fn plotter_port_flag_parses() { } } +// ---------- `fbuild build-progress` CLI shape ---------- + +#[test] +fn build_progress_parses_with_no_args() { + let cli = Cli::try_parse_from(["fbuild", "build-progress"]).expect("parse"); + assert!(matches!(cli.command, Some(Commands::BuildProgress))); +} + #[test] fn ide_select_with_no_project_dir_parses_as_select_action() { let cli = Cli::try_parse_from(["fbuild", "ide", "select"]).expect("parse"); diff --git a/crates/fbuild-daemon/src/handlers/build_progress.rs b/crates/fbuild-daemon/src/handlers/build_progress.rs new file mode 100644 index 00000000..772f8ab0 --- /dev/null +++ b/crates/fbuild-daemon/src/handlers/build_progress.rs @@ -0,0 +1,91 @@ +//! Build Progress web page (FastLED/fbuild#1076 Phase 2, second panel): a +//! daemon-served, self-contained HTML page that shows the daemon's current +//! build/deploy state and a live tail of daemon log activity. +//! +//! ## Observability reality (read before changing this file) +//! +//! Actual build output (compiler invocation lines, one NDJSON `log` event +//! per line) streams over `POST /api/build`'s response body +//! (`crates/fbuild-daemon/src/handlers/operations/build.rs`). That channel +//! is **strictly per-request**: the log lines flow through an `unbounded` +//! channel created fresh for that one HTTP request and forwarded straight +//! into that request's own streaming response body. There is no fan-out — +//! a second client (like this page) cannot attach to another client's +//! in-flight build and see its compiler output line-by-line, and adding +//! that would mean either buffering full compile logs server-side or +//! wiring a new broadcast channel through the build orchestrator, neither +//! of which is "reuse an existing cheap broadcast". +//! +//! What *is* already broadcast to every subscriber, cheaply, via the +//! existing [`crate::context::BroadcastHub`] (`ws_logs` / +//! `ws_status` in `handlers/websockets.rs`): +//! +//! - `/ws/logs` — every `tracing::*` event the daemon emits process-wide +//! (`BroadcastLogLayer`, wired in `main.rs`), including the build +//! handler's own lifecycle tracing (project-lock wait/acquire, client +//! disconnect/cancel, hard-deadline aborts, dependency-install +//! messages) and every other subsystem's events (deploy, esptool +//! write-flash progress, etc). It is not literal compiler stdout, but +//! it is a real-time, multi-subscriber view of what the daemon is +//! doing. +//! - `/api/daemon/info` (polled here every ~2s) and `/ws/status` (push, +//! not used here to keep this page's contract identical to a plain +//! poll loop) — `daemon_state`, `current_operation`, +//! `operation_in_progress`, `dependency_install`. +//! +//! So this page is built entirely out of **existing, unmodified** +//! endpoints: it polls `/api/daemon/info` for the status header and +//! attaches to `/ws/logs` for the scrolling log pane. No new daemon +//! endpoint or broadcast channel was added — per FastLED/fbuild#1076 +//! Phase 2's guidance to prefer reusing an existing broadcast channel +//! over inventing new server-side state. + +use axum::response::{Html, IntoResponse}; + +const BUILD_PROGRESS_PAGE_HTML: &str = include_str!("../../web/build-progress/index.html"); + +/// GET /build-progress — serve the self-contained Build Progress page. +pub async fn build_progress_page() -> impl IntoResponse { + Html(BUILD_PROGRESS_PAGE_HTML) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn build_progress_page_serves_html_with_no_external_deps() { + let response = build_progress_page().await.into_response(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + content_type.starts_with("text/html"), + "expected text/html content type, got {content_type}" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"); + let html = String::from_utf8(body.to_vec()).expect("body should be utf-8"); + + assert!( + html.contains("/api/daemon/info"), + "build progress page must poll the existing daemon info endpoint for status" + ); + assert!( + html.contains("/ws/logs"), + "build progress page must attach to the existing broadcast log websocket" + ); + assert!( + !html.contains("cdn.") + && !html.contains("unpkg.com") + && !html.contains("jsdelivr.net") + && !html.contains("googleapis.com"), + "build progress page must be self-contained with no CDN dependencies" + ); + } +} diff --git a/crates/fbuild-daemon/src/handlers/mod.rs b/crates/fbuild-daemon/src/handlers/mod.rs index f79213b1..d4136089 100644 --- a/crates/fbuild-daemon/src/handlers/mod.rs +++ b/crates/fbuild-daemon/src/handlers/mod.rs @@ -1,5 +1,6 @@ //! HTTP and WebSocket route handlers for the daemon. +pub mod build_progress; pub mod cache; pub mod devices; pub mod emulator; diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index 1a655c62..2eb9327d 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -9,7 +9,7 @@ use fbuild_daemon::context::{ BroadcastHub, DaemonContext, IDLE_TIMEOUT, STALE_LOCK_CHECK_INTERVAL, self_eviction_timeout, }; use fbuild_daemon::handlers::{ - cache, devices, emulator, health, locks, operations, plotter, websockets, + build_progress, cache, devices, emulator, health, locks, operations, plotter, websockets, }; use fbuild_daemon::log_layer::BroadcastLogLayer; use std::sync::Arc; @@ -206,6 +206,7 @@ async fn main() { .route("/emulator/avr8js/app.js", get(emulator::avr8js_app_js)) .route("/emulator/avr8js/:session_id", get(emulator::avr8js_page)) .route("/plotter", get(plotter::plotter_page)) + .route("/build-progress", get(build_progress::build_progress_page)) .route("/ws/serial-monitor", get(websockets::ws_serial_monitor)) .route("/ws/status", get(websockets::ws_status)) .route("/ws/logs", get(websockets::ws_logs)) diff --git a/crates/fbuild-daemon/tests/test_build_progress_route.rs b/crates/fbuild-daemon/tests/test_build_progress_route.rs new file mode 100644 index 00000000..69addbc4 --- /dev/null +++ b/crates/fbuild-daemon/tests/test_build_progress_route.rs @@ -0,0 +1,68 @@ +//! Integration test for `GET /build-progress` (FastLED/fbuild#1076 Phase 2). +//! +//! Mirrors `test_plotter_route.rs`: build a minimal `Router` wired exactly +//! like `main.rs`, spawn it on an ephemeral port, and assert the route +//! round-trips with the expected content — catching route-registration +//! regressions without needing the full production binary. + +use axum::Router; +use axum::routing::get; +use fbuild_daemon::handlers::build_progress; +use std::net::SocketAddr; +use std::time::Duration; + +fn build_test_app() -> Router { + Router::new().route("/build-progress", get(build_progress::build_progress_page)) +} + +async fn spawn_test_server() -> SocketAddr { + let app = build_test_app(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local_addr"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("axum::serve should not fail in test"); + }); + addr +} + +/// The `/build-progress` route is registered and serves a self-contained +/// HTML page that polls the existing daemon-info endpoint and attaches to +/// the existing broadcast log websocket. +#[tokio::test] +async fn build_progress_route_serves_html_page() { + let addr = spawn_test_server().await; + + let resp = fbuild_core::http::client_with_timeout(Duration::from_secs(10)) + .get(format!("http://{}/build-progress", addr)) + .timeout(Duration::from_secs(5)) + .send() + .await + .expect("GET /build-progress should not drop the connection"); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + content_type.starts_with("text/html"), + "expected text/html, got {content_type}" + ); + + let body = resp.text().await.expect("body should be readable"); + assert!( + body.contains("/api/daemon/info"), + "must poll the existing daemon info endpoint" + ); + assert!( + body.contains("/ws/logs"), + "must use the existing broadcast log websocket" + ); + assert!(body.contains("fbuild Build Progress")); +} diff --git a/crates/fbuild-daemon/web/build-progress/README.md b/crates/fbuild-daemon/web/build-progress/README.md new file mode 100644 index 00000000..bf2f4ea5 --- /dev/null +++ b/crates/fbuild-daemon/web/build-progress/README.md @@ -0,0 +1,25 @@ +# Build Progress Web Assets + +`index.html` is the self-contained Build Progress page served by +`fbuild-daemon` at `GET /build-progress` (FastLED/fbuild#1076 Phase 2, +`crates/fbuild-daemon/src/handlers/build_progress.rs`, embedded via +`include_str!` following the same pattern as `../plotter/index.html`). + +The page has no build step and no external dependencies. It is built +entirely out of existing, unmodified daemon endpoints: + +- `GET /api/daemon/info`, polled every ~2s, for the status header + (`daemon_state`, `current_operation`, `operation_in_progress`, + `dependency_install`, uptime/pid/version). +- `GET /ws/logs`, the existing `BroadcastHub`-backed WebSocket that every + daemon `tracing::*` event already flows through, for a live scrolling + log pane with an autoscroll toggle and clear button. + +See the "Observability reality" doc comment at the top of +`build_progress.rs` for why the page does *not* attach to the build's own +NDJSON output stream (`POST /api/build`): that stream is per-HTTP-request, +not broadcast, so a second client (this page) cannot observe another +client's in-flight build's compiler output without new server-side +broadcast plumbing. `/ws/logs` and `/api/daemon/info` are the observable, +already-broadcast alternative, so this page reuses them unmodified rather +than inventing new daemon state. diff --git a/crates/fbuild-daemon/web/build-progress/index.html b/crates/fbuild-daemon/web/build-progress/index.html new file mode 100644 index 00000000..f8b9c9bd --- /dev/null +++ b/crates/fbuild-daemon/web/build-progress/index.html @@ -0,0 +1,388 @@ + + + + + + fbuild Build Progress + + + +
+
+
fbuild Build Progress
+ + +
+
+
+ + Connecting… + +
+
+
+
+
+
+

Daemon activity

+ connecting… +
+

+    
+ +
+ + + + diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2a3b0784..645cc03e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -151,6 +151,7 @@ known limitations. | `fbuild ide [project_dir] [-e ] [--no-launch]` | Open the project as an IDE workspace on stock Zed. See [`fbuild ide`](#fbuild-ide) below. | | `fbuild ide select [project_dir] [-e ]` | Interactively (or with `-e`) choose the environment used for the IDE config, persist it, and regenerate. | | `fbuild plotter [-p ]` | Open the daemon-served Serial Plotter web page in the default browser. See [`fbuild plotter`](#fbuild-plotter) below. | +| `fbuild build-progress` | Open the daemon-served Build Progress web page in the default browser. See [`fbuild build-progress`](#fbuild-build-progress) below. | | `fbuild clang-tidy` | Run clang-tidy against project sources. | | `fbuild iwyu` | Run include-what-you-use analysis. | | `fbuild clang-query` | Run a clang-query matcher. | @@ -191,7 +192,8 @@ Generated/updated files: `lsp.clangd.binary.arguments`); safe to commit. - `.zed/tasks.json` — merge-don't-clobber: fbuild only replaces tasks whose label starts with `"fbuild: "` (Build, Build (clean), Deploy, Deploy + - Monitor, Monitor, Reset, Serial Plotter, Select environment); any other + Monitor, Monitor, Reset, Serial Plotter, Build Progress, Select + environment); any other task you've added is left untouched. Safe to commit. - `.fbuild/ide_state.json` — the persisted environment choice. Local developer state; recommend `.gitignore`. @@ -267,6 +269,41 @@ generates a `"fbuild: Serial Plotter"` Zed task that just runs `fbuild plotter` (see [`fbuild ide`](#fbuild-ide) above), so the same command works whether or not you're using Zed. +### `fbuild build-progress` + +Open the daemon-served Build Progress web page in the default browser +(FastLED/fbuild#1076 Phase 2, second panel). The page (`GET +/build-progress` on the daemon, +`crates/fbuild-daemon/web/build-progress/index.html`) is a single +self-contained HTML file with no external dependencies. It is built +entirely out of existing, unmodified daemon endpoints: + +- `GET /api/daemon/info`, polled every ~2s, for a status header (idle / + building / deploying / ..., the current operation description, and any + in-progress dependency install). +- `GET /ws/logs`, the existing daemon-wide broadcast log WebSocket, for a + live scrolling activity pane with an autoscroll toggle and a clear + button. + +Note this is daemon-wide activity, not a literal line-by-line tail of one +build's compiler output: the build's own NDJSON log stream +(`POST /api/build`) is scoped to the HTTP request that started it and +isn't broadcast to other clients, so a second page can't attach to +"someone else's" in-flight build output without new server-side broadcast +plumbing. `/ws/logs` and `/api/daemon/info` are the observable, +already-broadcast alternative this page uses instead — see the +"Observability reality" comment in +`crates/fbuild-daemon/src/handlers/build_progress.rs` for the full +rationale. + +```bash +fbuild build-progress # opens the page +``` + +`fbuild ide` generates a `"fbuild: Build Progress"` Zed task that just runs +`fbuild build-progress`, so the same command works whether or not you're +using Zed. + ## Batch And CI Commands ### `fbuild compile-many`