diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc966315..b032f2785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check portable shell scripts + run: /bin/sh tests/shell-scripts.sh + # The Tauri stack needs a webview toolchain to compile at all. - name: Install Tauri system dependencies run: | diff --git a/.github/workflows/qa-panel.yml b/.github/workflows/qa-panel.yml index 8bc2a9169..5b98336ad 100644 --- a/.github/workflows/qa-panel.yml +++ b/.github/workflows/qa-panel.yml @@ -9,10 +9,9 @@ name: QA panel # frontend and QA changes while making a UI dependency bump prove the exact # package it resolves before that pull request can merge. # -# Pull requests run the input-component contract groups and tree integrity. -# Manual runs can execute every declared outcome and optionally reconcile every -# control instance, keeping historical whole-application debt separate from a -# focused component regression gate. +# Pull requests run every declared outcome plus tree integrity. Manual runs can +# still select a focused group while authoring, or add inventory reconciliation +# and lifecycle checks for the exhaustive release audit. on: pull_request: paths: @@ -21,6 +20,7 @@ on: - 'apps/gui/frontend/src/**' - 'ps-qa.ron' - 'scripts/qa-profile-restore.sh' + - 'scripts/qa-memory-navigation.sh' - 'scripts/qa-run-groups.sh' - 'tests/ps-qa/**' workflow_dispatch: @@ -53,6 +53,9 @@ jobs: # for a cold release build and cleanup, but never leave a stale QA process # occupying a runner for an hour. timeout-minutes: 30 + defaults: + run: + shell: zsh {0} steps: - uses: actions/checkout@v4 @@ -71,8 +74,7 @@ jobs: # spending minutes building or launching an app with an incompatible # harness release. - name: Install ps-qa - # Compatible 0.4 releases preserve the reviewed QA profile contract. - run: cargo install ps-qa --version '^0.4.4' + run: cargo install ps-qa - name: Validate the QA profile and outcome manifest run: ps-qa --app ps-qa.ron list --checks tests/ps-qa @@ -120,6 +122,7 @@ jobs: run: | launch_app() { HOME=/tmp/qa-home AZ_DATA_DIR=/tmp/qa-profile-db \ + AZ_QA_WORKSPACE_ROOT=/tmp/qa-profile-db-workspace \ ./target/release/az-gui --blitz-control > /tmp/az-qa.log 2>&1 & echo $! > /tmp/az-qa.pid } @@ -169,7 +172,7 @@ jobs: - name: Run the focused input checks id: checks - if: ${{ github.event_name == 'pull_request' || inputs.group != '' || inputs.suite == 'focused' }} + if: ${{ github.event_name == 'workflow_dispatch' && (inputs.group != '' || inputs.suite == 'focused') }} continue-on-error: true timeout-minutes: 3 env: @@ -198,7 +201,7 @@ jobs: # a blank group, after the focused regression is green. - name: Run the complete behavior audit id: full - if: ${{ github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite != 'focused' }} + if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite != 'focused') }} continue-on-error: true timeout-minutes: 3 env: @@ -217,7 +220,7 @@ jobs: timeout-minutes: 3 run: | set -o pipefail - ps-qa inventory 2>&1 | tee /tmp/cover.txt + ps-qa inventory --require-outcomes 2>&1 | tee /tmp/cover.txt components="$(awk '/^components:/ { print $2; exit }' /tmp/cover.txt)" unverified="$(awk '/^unverified:/ { print $2; exit }' /tmp/cover.txt)" isolated="$(awk '/^isolated:/ { print $2; exit }' /tmp/cover.txt)" @@ -238,19 +241,19 @@ jobs: ps-qa reconcile /tmp/cover.txt --checks tests/ps-qa \ 2>&1 | tee /tmp/sweep.txt + - name: Verify native navigation memory plateaus + if: ${{ github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite == 'exhaustive' && steps.full.outcome == 'success' }} + timeout-minutes: 2 + run: scripts/qa-memory-navigation.sh + - name: Verify the inspection toggle disconnects control id: isolated - if: ${{ github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite == 'exhaustive' && steps.full.outcome == 'success' }} + if: ${{ (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite == 'exhaustive')) && steps.full.outcome == 'success' }} continue-on-error: true run: | - ps-qa click Settings - sleep 2 - ps-qa click "Enable inspection and agent control" \ + ps-qa click '#tabs-settings' + ps-qa click '#settings-inspection-control' \ > /tmp/isolated.txt 2>&1 || true - if ps-qa nodes > /tmp/isolated-probe.txt 2>&1; then - ps-qa click "Enable inspection and agent control" \ - >> /tmp/isolated.txt 2>&1 || true - fi disconnected=0 for _ in $(seq 1 20); do if ! ps-qa nodes > /tmp/isolated-probe.txt 2>&1; then @@ -268,22 +271,41 @@ jobs: # stopped at the first failure and silently hid later lifecycle failures. - name: Enforce complete QA outcome if: always() + env: + EVENT_NAME: ${{ github.event_name }} + REQUESTED_GROUP: ${{ inputs.group }} + REQUESTED_SUITE: ${{ inputs.suite }} + INTEGRITY_OUTCOME: ${{ steps.integrity.outcome }} + FOCUSED_OUTCOME: ${{ steps.checks.outcome }} + FULL_OUTCOME: ${{ steps.full.outcome }} + INVENTORY_OUTCOME: ${{ steps.inventory.outcome }} + SWEEP_OUTCOME: ${{ steps.sweep.outcome }} + ISOLATED_OUTCOME: ${{ steps.isolated.outcome }} run: | failed=0 - for gate in \ - 'tree integrity:${{ steps.integrity.outcome }}' \ - 'focused input checks:${{ steps.checks.outcome }}' \ - 'complete behavior audit:${{ steps.full.outcome }}' \ - 'reachability inventory:${{ steps.inventory.outcome }}' \ - 'semantic sweep:${{ steps.sweep.outcome }}' \ - 'inspection toggle:${{ steps.isolated.outcome }}'; do - name="${gate%%:*}" - outcome="${gate#*:}" - if [ "$outcome" = failure ]; then + require_gate() { + local name="$1" + local outcome="$2" + if [[ "$outcome" != success ]]; then echo "$name failed" >&2 failed=1 fi - done + } + + require_gate 'tree integrity' "$INTEGRITY_OUTCOME" + if [[ "$EVENT_NAME" == pull_request ]]; then + require_gate 'complete behavior audit' "$FULL_OUTCOME" + require_gate 'inspection toggle' "$ISOLATED_OUTCOME" + elif [[ -n "$REQUESTED_GROUP" || "$REQUESTED_SUITE" == focused ]]; then + require_gate 'focused input checks' "$FOCUSED_OUTCOME" + else + require_gate 'complete behavior audit' "$FULL_OUTCOME" + fi + if [[ "$EVENT_NAME" == workflow_dispatch && -z "$REQUESTED_GROUP" && "$REQUESTED_SUITE" == exhaustive ]]; then + require_gate 'reachability inventory' "$INVENTORY_OUTCOME" + require_gate 'semantic sweep' "$SWEEP_OUTCOME" + require_gate 'inspection toggle' "$ISOLATED_OUTCOME" + fi exit "$failed" - name: Summary diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index 996704d2d..31867ad44 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -88,7 +88,7 @@ jobs: # See the same step in release.yml: the crates.io build of the CLI the # npm package shims, so the release path never needs Node. - name: Install the Tauri CLI - run: cargo install tauri-cli --version "^2.11" + run: cargo install tauri-cli - name: Build the AgencyProxy sidecar run: scripts/stage-agency-proxy-sidecar.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c4c6a53d..72a4ddfc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,7 +147,7 @@ jobs: # package is a thin Node shim around this binary, so taking it from # crates.io is what keeps Node out of the release path entirely. - name: Install the Tauri CLI - run: cargo install tauri-cli --version "^2.11" + run: cargo install tauri-cli - name: Build the AgencyProxy sidecar run: scripts/stage-agency-proxy-sidecar.sh diff --git a/Cargo.toml b/Cargo.toml index eda99491a..297e9c11d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.8.41" +version = "0.8.48" edition = "2024" publish = false @@ -21,8 +21,12 @@ az-core = { path = "crates/core" } # time rather than at build time. Both are declared here so the pair cannot # drift: the crates inherit these, and sidecar staging asks cargo for the # resolved version instead of re-parsing the manifest to check they match. -agency-proxy-client = "0.1.7" -agency-proxy-protocol = "0.1.7" +agency-proxy-client = "^0.1.8" +agency-proxy-protocol = "^0.1.8" +# The GUI and both storage tools compile the same schema against one resolved +# WorkTable package. A workspace dependency prevents their compatible ranges +# from drifting into separate copies without freezing the selected patch. +worktable = "1.0.0-beta.11" # Heavy deps are declared per-crate on purpose: only apps/gui pulls in the # Tauri stack, so agent/proxy builds never trigger a webview toolchain build. @@ -45,6 +49,38 @@ agency-proxy-protocol = "0.1.7" debug = 0 incremental = true +# The frontend is JavaScript interpreted by Boa. Leaving the interpreter at +# dev's default opt-level 0 made a small Solid reaction take ~300ms in the QA +# build while the renderer itself took single-digit milliseconds. Keep AZ's own +# crates quick to compile, but build the long-lived script runtime like runtime +# code rather than like code under source-level debugging. +[profile.dev.package.ps-boa-engine] +opt-level = 2 + +[profile.dev.package.ps-boa-gc] +opt-level = 2 + +[profile.dev.package.ps-boa-string] +opt-level = 2 + +[profile.dev.package.ps-blitz-script] +opt-level = 2 + +# Pixel QA uses the renderer's real CPU paint path. At opt-level 0 even a +# 32x32 crop spends seconds repainting the window, so the inspector times out +# before it can compare first paint with the post-hover frame. +[profile.dev.package.ps-blitz-paint] +opt-level = 2 + +[profile.dev.package.ps-anyrender-vello-cpu] +opt-level = 2 + +[profile.dev.package.ps-vello-cpu] +opt-level = 2 + +[profile.dev.package.ps-glifo] +opt-level = 2 + [profile.test] # Tests need assertions and line-bearing panic output, not debugger symbols. debug = 0 diff --git a/apps/blitz-preview/Cargo.toml b/apps/blitz-preview/Cargo.toml index ee98e6070..3d09a7929 100644 --- a/apps/blitz-preview/Cargo.toml +++ b/apps/blitz-preview/Cargo.toml @@ -14,14 +14,14 @@ build = "build.rs" # with it the only genuinely headless path, from building at all. anyrender = { package = "ps-anyrender", version = "^0.13.0" } anyrender_vello_cpu = { package = "ps-anyrender-vello-cpu", version = "^0.16.0", optional = true } -blitz-dom = { package = "ps-blitz-dom", version = "^0.3.0-beta.11", features = ["system-fonts"] } -blitz-paint = { package = "ps-blitz-paint", version = "^0.3.0-beta.11", features = ["scrollbars"] } -blitz-script = { package = "ps-blitz-script", version = "^0.3.0-beta.11", features = ["system-fonts"] } -blitz-traits = { package = "ps-blitz-traits", version = "^0.3.0-beta.11" } +blitz-dom = { package = "ps-blitz-dom", version = "^0.3", features = ["system-fonts"] } +blitz-paint = { package = "ps-blitz-paint", version = "^0.3", features = ["scrollbars"] } +blitz-script = { package = "ps-blitz-script", version = "^0.3", features = ["system-fonts"] } +blitz-traits = { package = "ps-blitz-traits", version = "^0.3" } brotli = { version = "^8.0.4", default-features = false, features = ["std"] } png = { version = "^0.18.1", optional = true } tauri = { version = "^2.11.5", default-features = false, features = ["compression"] } -tauri-runtime-blitz = "^0.1.0" +tauri-runtime-blitz = "^0.3" url = "^2.5.8" # For `--offscreen`: the activation policy has to be set before any window @@ -36,7 +36,9 @@ brotli = { version = "^8.0.4", default-features = false, features = ["std"] } tauri-build = { version = "^2.6.3", features = [] } [dev-dependencies] -usvg = "^0.46.0" +# Use the renderer's SVG fork so geometry assertions inspect the exact tree +# type produced by Blitz instead of compiling a second, incompatible `usvg`. +usvg = { package = "ps-usvg", version = "^0.48.1" } [features] capture = ["dep:anyrender_vello_cpu", "dep:png"] diff --git a/apps/blitz-preview/build.rs b/apps/blitz-preview/build.rs index 438152fee..445c8546c 100644 --- a/apps/blitz-preview/build.rs +++ b/apps/blitz-preview/build.rs @@ -38,9 +38,6 @@ fn compress_asset(path: &Path, output: &Path, quality: u32) -> usize { input.len() } - - - /// Drop framework load commands that nothing in this binary references. /// /// On macOS `tauri` and `tauri-runtime` depend on `objc2-web-kit` diff --git a/apps/blitz-preview/src/main.rs b/apps/blitz-preview/src/main.rs index 3f62dd340..fe0a8e0eb 100644 --- a/apps/blitz-preview/src/main.rs +++ b/apps/blitz-preview/src/main.rs @@ -189,7 +189,6 @@ fn create_dist_document(dist: &std::path::Path, url: &str) -> Result Result<(), String> { use anyrender::render_to_buffer; @@ -399,8 +398,7 @@ fn main() { #[cfg(target_os = "macos")] if std::env::args().any(|argument| argument == "--offscreen") { use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; - let mtm = objc2::MainThreadMarker::new() - .expect("main() runs on the main thread"); + let mtm = objc2::MainThreadMarker::new().expect("main() runs on the main thread"); let application = NSApplication::sharedApplication(mtm); application.setActivationPolicy(NSApplicationActivationPolicy::Accessory); /* @@ -411,9 +409,7 @@ fn main() { * activation-policy default here is what makes the accessory policy * stick through window creation. */ - unsafe { - application.setActivationPolicy(NSApplicationActivationPolicy::Prohibited); - } + application.setActivationPolicy(NSApplicationActivationPolicy::Prohibited); trace("activation policy set to prohibited"); } #[cfg(not(test))] @@ -459,6 +455,7 @@ fn main() { * is the same thing for the windowed path, under its own name so a headless * capture and a live window can be aimed at different builds. */ + #[cfg(not(test))] match std::env::var_os("BLITZ_PREVIEW_DIST") { Some(dist) => { let dist = std::path::PathBuf::from(dist); @@ -467,11 +464,13 @@ fn main() { } None => set_document_factory(create_document), } + #[cfg(test)] + set_document_factory(create_document); trace("document factory configured"); let context = tauri::generate_context!("tauri.conf.json"); trace("Tauri context generated"); - let mut app = builder() + let app = builder() .invoke_handler(tauri::generate_handler![greet, list_capabilities]) .build(context) .expect("AgencyZero Tauri Blitz preview failed to build"); @@ -531,7 +530,6 @@ fn main() { #[cfg(test)] mod tests { use super::*; - use std::collections::HashSet; #[test] fn embedded_assets_are_compressed_and_round_trip() { @@ -559,17 +557,17 @@ mod tests { } #[test] - fn production_icon_uses_resolve_to_nonempty_svg_images() { + fn production_icons_resolve_to_nonempty_svg_images() { std::thread::Builder::new() .name("production-icon-test".to_owned()) .stack_size(16 * 1024 * 1024) - .spawn(assert_production_icon_uses_resolve) + .spawn(assert_production_icons_resolve) .unwrap() .join() .unwrap(); } - fn assert_production_icon_uses_resolve() { + fn assert_production_icons_resolve() { fn count_painted_paths(group: &usvg::Group) -> usize { group .children() @@ -598,41 +596,20 @@ mod tests { document.inner_mut().resolve(0.0); let doc = document.inner(); - let use_ids = doc.query_selector_all("svg use").unwrap(); - assert!( - use_ids.len() > 20, - "production workspace did not finish rendering" - ); - - let mut svg_ids = HashSet::new(); - for use_id in use_ids { - let mut current = doc.get_node(use_id).and_then(|node| node.parent); - while let Some(node_id) = current { - let node = doc.get_node(node_id).unwrap(); - if node - .element_data() - .is_some_and(|element| element.name.local.as_ref() == "svg") - { - svg_ids.insert(node_id); - break; - } - current = node.parent; - } - } - + let svg_ids = doc.query_selector_all("svg").unwrap(); assert!( !svg_ids.is_empty(), - "no visible SVG ancestors found for uses" + "production workspace rendered no SVG icons" ); for svg_id in svg_ids { let node = doc.get_node(svg_id).unwrap(); let tree = node .element_data() .and_then(|element| element.svg_data()) - .expect("visible SVG use was not parsed as an image"); + .expect("visible SVG was not parsed as an image"); assert!( count_painted_paths(tree.root()) > 0, - "visible SVG use parsed without any paintable paths" + "visible SVG parsed without any paintable paths" ); } } diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index edc0deeac..53dc7c24c 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -71,13 +71,9 @@ agent-experimental = { version = "^0.1.3", default-features = false, optional = promptsyntax = "0.2.0" shlex = "1.3" az-core.workspace = true -# Exact rather than a caret, unlike the engine deps above, and from the -# registry rather than a git rev. Master has moved on to an `arctic-wt` that -# cargo cannot select alongside this workspace, so tracking it fails to resolve -# rather than building something newer. beta.11 is published; moving to it is a -# storage-engine change and belongs in its own commit with its own testing, not -# in a dependency-source cleanup. -worktable = "=1.0.0-beta.10" +# Shared with the migration and headless tools so one schema cannot resolve +# against a different WorkTable implementation. +worktable.workspace = true # The `worktable!` macro emits code that names these by bare path rather than # through a re-export, so a consumer of the macro has to declare them too. # Versions match worktable 1.0 beta's own, since a mismatch produces errors that name @@ -101,7 +97,7 @@ tauri = { version = "2", default-features = false, features = ["macos-private-ap # 0.1.0 the runtime's own feature forwards to `tauri` and `tauri-runtime`, so # naming it here agrees with that rather than being the only thing holding the # two sides together. -tauri-runtime-blitz = { version = "^0.1.11", optional = true, features = ["macos-private-api"] } +tauri-runtime-blitz = { version = "^0.3", optional = true, features = ["macos-private-api"] } # # The engine by version, not by branch. Same move `chuzz` made, for the same # reasons its manifest records. @@ -121,8 +117,8 @@ tauri-runtime-blitz = { version = "^0.1.11", optional = true, features = ["macos # file. Working against a local checkout stays possible through the `[patch]` # entries in `.cargo/config.toml`, so the default exercises what a release # actually resolves. -blitz-dom = { package = "ps-blitz-dom", version = "^0.3.0", features = ["system-fonts", "parallel-construct"], optional = true } -blitz-script = { package = "ps-blitz-script", version = "^0.3.0", features = ["system-fonts"], optional = true } +blitz-dom = { package = "ps-blitz-dom", version = "^0.3", features = ["system-fonts", "parallel-construct"], optional = true } +blitz-script = { package = "ps-blitz-script", version = "^0.3", features = ["system-fonts"], optional = true } brotli = { version = "8.0.4", default-features = false, features = ["std"], optional = true } url = { version = "2.5.8", optional = true } tauri-plugin-updater = "2" diff --git a/apps/gui/build.rs b/apps/gui/build.rs index 99300a304..5b8e75284 100644 --- a/apps/gui/build.rs +++ b/apps/gui/build.rs @@ -129,6 +129,8 @@ fn stamp_build() { println!("cargo:rerun-if-changed=../../Cargo.toml"); println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-changed=frontend/node_modules/@pathscale/ui/package.json"); + println!("cargo:rerun-if-changed=frontend/node_modules/solid-js/package.json"); + println!("cargo:rerun-if-changed=frontend/node_modules/@solidjs/web/package.json"); } /// Drop framework load commands that nothing in this binary references. @@ -200,12 +202,25 @@ fn write_resolved_manifest() { // The whole Rust graph, deduplicated. A path or git source shows up here // as its source, which is the thing that must never ship unnoticed. - let tree = std::process::Command::new("cargo") - .args(["tree", "--edges", "normal", "--prefix", "none", "--quiet"]) - .output() - .ok() - .filter(|out| out.status.success()) - .and_then(|out| String::from_utf8(out.stdout).ok()) + let exact_tree = std::env::var_os("AZ_RESOLVED_RUST_GRAPH").map(PathBuf::from); + let tree_config = std::env::var("AZ_CARGO_TREE_CONFIG").ok(); + let tree = exact_tree + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .or_else(|| { + let mut command = std::process::Command::new("cargo"); + if let Some(config) = tree_config.as_deref() { + for value in config.split('|').filter(|value| !value.is_empty()) { + command.arg("--config").arg(value); + } + } + command + .args(["tree", "--edges", "normal", "--prefix", "none", "--quiet"]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + }) .unwrap_or_else(|| "cargo tree unavailable\n".into()); let mut crates: Vec<&str> = tree .lines() @@ -223,19 +238,71 @@ fn write_resolved_manifest() { // The component library as installed, not as requested. `bun.lock` is not // committed in this project, so the caret in `package.json` cannot answer // which version is on disk. - let ui = std::fs::read_to_string("frontend/node_modules/@pathscale/ui/package.json") - .ok() - .and_then(|text| { + let package_version = |path: &Path| { + std::fs::read_to_string(path).ok().and_then(|text| { text.lines() .find(|line| line.trim_start().starts_with("\"version\"")) .and_then(|line| line.split('"').nth(3).map(str::to_owned)) }) - .unwrap_or_else(|| "unknown".into()); - manifest.push_str(&format!("\n# frontend\n@pathscale/ui {ui}\n")); + }; + let local_ui_root = std::env::var_os("AZ_UI_DIST").and_then(|entry| { + PathBuf::from(entry) + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + }); + let ui_package = local_ui_root.as_ref().map_or_else( + || PathBuf::from("frontend/node_modules/@pathscale/ui/package.json"), + |root| root.join("package.json"), + ); + let ui = package_version(&ui_package); + let solid = package_version(Path::new("frontend/node_modules/solid-js/package.json")); + let solid_web = package_version(Path::new("frontend/node_modules/@solidjs/web/package.json")); + if let (Some(solid), Some(solid_web)) = (&solid, &solid_web) { + assert!( + solid == solid_web && solid.starts_with("2."), + "AZ requires one matching Solid 2 runtime; resolved solid-js={solid}, @solidjs/web={solid_web}" + ); + } + manifest.push_str("\n# frontend\n"); + for (name, version) in [ + ("@pathscale/ui", ui), + ("solid-js", solid), + ("@solidjs/web", solid_web), + ] { + manifest.push_str(name); + manifest.push(' '); + manifest.push_str(version.as_deref().unwrap_or("unknown")); + if name == "@pathscale/ui" + && let Some(root) = &local_ui_root + { + manifest.push_str(" ("); + manifest.push_str(&root.display().to_string()); + manifest.push(')'); + } + manifest.push('\n'); + } std::fs::write(&path, manifest).expect("write resolved-manifest.txt"); println!("cargo:rerun-if-changed=../../Cargo.toml"); + println!("cargo:rerun-if-changed=../../Cargo.lock"); println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-changed=frontend/node_modules/@pathscale/ui/package.json"); + println!("cargo:rerun-if-env-changed=AZ_RESOLVED_RUST_GRAPH"); + println!("cargo:rerun-if-env-changed=AZ_CARGO_TREE_CONFIG"); + println!("cargo:rerun-if-env-changed=AZ_UI_DIST"); + if let Some(path) = exact_tree { + println!("cargo:rerun-if-changed={}", path.display()); + } + if let Some(root) = local_ui_root { + println!( + "cargo:rerun-if-changed={}", + root.join("package.json").display() + ); + println!( + "cargo:rerun-if-changed={}", + root.join("dist/layouts.manifest.json").display() + ); + } } diff --git a/apps/gui/frontend/package.json b/apps/gui/frontend/package.json index ac387c69a..facffd4ba 100644 --- a/apps/gui/frontend/package.json +++ b/apps/gui/frontend/package.json @@ -13,7 +13,7 @@ "build": "bun run typecheck && bun --bun rsbuild build", "preview": "bun --bun rsbuild preview", "typecheck": "bun --bun tsc --noEmit", - "lint": "./scripts/biome.sh check .", + "lint": "./scripts/biome.sh check . && bun scripts/check-style-contracts.ts && bun scripts/check-ui-controls.ts", "lint:ui-controls": "bun scripts/check-ui-controls.ts", "format": "./scripts/biome.sh format . --write", "test": "bun --bun vitest", @@ -24,11 +24,12 @@ "license": "MIT", "dependencies": { "@pathscale/ui": "^2.11.6", + "@solidjs/web": "next", "@tauri-apps/api": "^2.1.1", "clsx": "^2.1.1", "popmotion": "^11.0.5", "promptsyntax": "^0.1.0", - "solid-js": "^2.0.0-rc.0", + "solid-js": "next", "solid-layouts": "^0.2.1", "tailwind-merge": "^3.6.0" }, diff --git a/apps/gui/frontend/rsbuild.config.ts b/apps/gui/frontend/rsbuild.config.ts index 20efeef07..ace0b8709 100644 --- a/apps/gui/frontend/rsbuild.config.ts +++ b/apps/gui/frontend/rsbuild.config.ts @@ -2,22 +2,68 @@ import { defineConfig } from "@rsbuild/core"; import { pluginBabel } from "@rsbuild/plugin-babel"; import { pluginSolid } from "@rsbuild/plugin-solid"; import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; +import { resolve } from "node:path"; import { pluginSolidLayoutsApplication } from "rsbuild-plugin-solid-layouts"; +const localUiDist = process.env.AZ_UI_DIST; +const localUiRoot = localUiDist ? resolve(localUiDist, "../..") : undefined; +const packageVersion = async (name: string) => + ((await Bun.file(resolve(__dirname, "node_modules", name, "package.json")).json()) as { + version: string; + }).version; +const solidVersion = await packageVersion("solid-js"); +const solidWebVersion = await packageVersion("@solidjs/web"); + +if (localUiDist && localUiRoot) { + for (const required of [ + localUiDist, + resolve(localUiRoot, "package.json"), + resolve(localUiRoot, "dist", "layouts.manifest.json"), + ]) { + if (!(await Bun.file(required).exists())) { + throw new Error(`AZ_UI_DIST dependency is incomplete: ${required} does not exist`); + } + } +} + +if (solidVersion !== solidWebVersion || !solidVersion.startsWith("2.")) { + throw new Error( + `AZ requires one matching Solid 2 runtime; resolved solid-js=${solidVersion}, @solidjs/web=${solidWebVersion}`, + ); +} + export default defineConfig({ plugins: [ // Must run before Babel and Solid. Once the Solid JSX transform has run // there is no @@ -337,7 +230,7 @@ export function BootFailed(props: { */ function MockBanner(): JSX.Element { return ( -
+
{tx("Design fixtures — the Rust commands are not implemented yet")}
diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 5d3fdcc26..90d714c6a 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -20,6 +20,7 @@ import type { PricingTable, Project, ProjectItem, + ProjectPanelData, ProjectStatus, PullRequest, Question, @@ -281,6 +282,9 @@ export interface AgencyZeroApi { */ compactProject(projectId: string, agent: Agent): Promise; + /** One cached-load boundary for every low-churn project-panel control. */ + getProjectPanelData(projectId: string): Promise; + /** * What this project's agent keeps across compactions. * @@ -394,6 +398,10 @@ export interface AgencyZeroApi { * replaced. */ relaunchApp(): Promise; + /** Confirm every backend event subscription is installed for this boot. */ + frontendSubscriptionsReady(): Promise; + /** Release the backend restart barrier after frontend-owned work drains. */ + confirmAgentRestart(token: string): Promise; /** Where the Home task manager's conversation stands. */ getTaskManager(): Promise; /** @@ -411,6 +419,8 @@ export interface AgencyZeroApi { /** Every broadcast the window listens for, and what rides on it. */ export interface AppEvents { + /** An agent-authored restart is waiting for frontend-owned queued work. */ + "app:restart-scheduled": { token: string }; "settings:updated": GlobalSettings; "project:created": Project; "project:updated": Project; @@ -560,6 +570,8 @@ export interface AppEvents { stop: string; exitCode: number | null; }; + /** The backend removed the exact reservation that owned this project slot. */ + "run:slot_released": { projectId: string }; /** A WorkTable worker became terminal; the window must warn immediately. */ "persistence:failed": { message: string }; } diff --git a/apps/gui/frontend/src/api/index.ts b/apps/gui/frontend/src/api/index.ts index 1be8e9b1d..d6d49a545 100644 --- a/apps/gui/frontend/src/api/index.ts +++ b/apps/gui/frontend/src/api/index.ts @@ -73,6 +73,8 @@ const COMMAND_FOR: Record = { getStudySummary: "get_study_summary", exportStudyEvents: "export_study_events", clearStudyEvents: "clear_study_events", + frontendSubscriptionsReady: "frontend_subscriptions_ready", + confirmAgentRestart: "confirm_agent_restart", claudeUsage: "claude_usage", listAgentStatus: "list_agent_status", listModels: "list_models", @@ -96,6 +98,7 @@ const COMMAND_FOR: Record = { createWorkspaceRoot: "create_workspace_root", cancelRun: "cancel_run", compactProject: "compact_project", + getProjectPanelData: "get_project_panel_data", getCheckpoints: "get_checkpoints", setCheckpoints: "set_checkpoints", getProjectConcise: "get_project_concise", diff --git a/apps/gui/frontend/src/api/mock.test.ts b/apps/gui/frontend/src/api/mock.test.ts index 821f03c3c..38a221450 100644 --- a/apps/gui/frontend/src/api/mock.test.ts +++ b/apps/gui/frontend/src/api/mock.test.ts @@ -169,13 +169,16 @@ describe("tasks", () => { it("cancelling the run empties Running for that project only", async () => { const stopped = vi.fn(); + const released = vi.fn(); await api.on("run:stopped", stopped); + await api.on("run:slot_released", released); await api.cancelRun("worktable"); expect(await api.listRunningTasks("worktable")).toHaveLength(0); expect(await api.listRunningTasks("cafe")).toHaveLength(1); expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ projectId: "worktable" })); + expect(released).toHaveBeenCalledWith({ projectId: "worktable" }); }); /* diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index 72ecbcc9b..8bc6f9e0b 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -9,6 +9,7 @@ import type { Message, Project, ProjectItem, + ProjectPanelData, ProjectStatus, Question, QuotaReport, @@ -786,6 +787,7 @@ export function createMockApi(): AgencyZeroApi { stop: "canceled", exitCode: null, }); + emit("run:slot_released", { projectId }); return settle(undefined); }, @@ -797,6 +799,18 @@ export function createMockApi(): AgencyZeroApi { */ compactProject: () => Promise.reject(new Error("the mock has no agent session to compact")), + getProjectPanelData: (projectId) => + settle({ + ioPersist: false, + notes: notes.get(projectId) ?? "", + responseVerbosity: (responseVerbosity.get(projectId) ?? + "default") as ProjectPanelData["responseVerbosity"], + contextDetail: (verbosityByProject.get(projectId) ?? + "adaptive") as ProjectPanelData["contextDetail"], + checkpoints: checkpoints.has(projectId), + approvalRules: ["Bash: cargo test", "Edit: apps/gui/src"], + } satisfies ProjectPanelData), + /* * Notes are real here, unlike the compaction that produces them. * @@ -1174,6 +1188,8 @@ export function createMockApi(): AgencyZeroApi { // A browser tab cannot exec itself; the button is greyed off-Tauri anyway. relaunchApp: () => settle(undefined), + frontendSubscriptionsReady: () => settle(undefined), + confirmAgentRestart: () => settle(undefined), async on( event: E, diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index 3f290c11d..ac6a9c530 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -100,6 +100,8 @@ export function createCommandApi(call: CommandCaller, on: EventListener): Agency getStudySummary: () => call("get_study_summary"), exportStudyEvents: () => call("export_study_events"), clearStudyEvents: () => call("clear_study_events"), + frontendSubscriptionsReady: () => call("frontend_subscriptions_ready"), + confirmAgentRestart: (token) => call("confirm_agent_restart", { token }), claudeUsage: () => call("claude_usage"), listAgentStatus: (recheck) => call("list_agent_status", { recheck }), listModels: (discover) => call("list_models", { discover }), @@ -124,6 +126,7 @@ export function createCommandApi(call: CommandCaller, on: EventListener): Agency cancelRun: (projectId) => call("cancel_run", { projectId }), compactProject: (projectId, agent) => call("compact_project", { projectId, agent }), + getProjectPanelData: (projectId) => call("get_project_panel_data", { projectId }), getCheckpoints: (projectId) => call("get_checkpoints", { projectId }), setCheckpoints: (projectId, enabled) => call("set_checkpoints", { projectId, enabled }), getProjectConcise: (projectId) => call("get_project_concise", { projectId }), diff --git a/apps/gui/frontend/src/components/EditableTitle.tsx b/apps/gui/frontend/src/components/EditableTitle.tsx index e58013342..bc83f9c53 100644 --- a/apps/gui/frontend/src/components/EditableTitle.tsx +++ b/apps/gui/frontend/src/components/EditableTitle.tsx @@ -13,6 +13,7 @@ import { tx } from "~/stores/i18n"; * the accessible names, which are translated. */ export function EditableTitle(props: { + id: string; value: string; onRename: (name: string) => Promise; class?: string; @@ -32,15 +33,17 @@ export function EditableTitle(props: { return ( } + trigger={} class={props.class} fieldClass={props.inputClass} > {props.onActivate ? ( {props.lead}
@@ -164,11 +146,11 @@ export function SectionPanel(props: SectionPanelProps): JSX.Element { editor and log control behind `hidden` made each tab switch rebuild thousands of unreachable nodes. Mounting the body only while open is the standard disclosure lifecycle and keeps the semantic tree honest. */} - + {props.isOpen ? (
- {props.children} + {props.children()}
-
+ ) : null} ); } diff --git a/apps/gui/frontend/src/components/PillMenu.tsx b/apps/gui/frontend/src/components/PillMenu.tsx index ea2603eee..ad2ffa396 100644 --- a/apps/gui/frontend/src/components/PillMenu.tsx +++ b/apps/gui/frontend/src/components/PillMenu.tsx @@ -14,6 +14,8 @@ export type PillOption = { }; export type PillMenuProps = { + /** Stable application identity. Compound parts derive their own ids from it. */ + id: string; /** Bold prefix that never changes, e.g. the agent name next to the model. */ prefix?: string; value: T; @@ -42,11 +44,12 @@ export function PillMenu(props: PillMenuProps): JSX.Element // Opens upward: every pill in this app sits in a composer at the window's bottom edge. return ( - + (props: PillMenuProps): JSX.Element > {(name) => ( - + )} @@ -63,25 +66,27 @@ export function PillMenu(props: PillMenuProps): JSX.Element {current()?.triggerLabel ?? currentLabel()} - + {(option) => ( props.onChange(option.value)} class={`flex w-full flex-col items-start gap-0.5 rounded-lg px-3 py-2 text-left transition-colors hover:bg-white/5 ${ option.value === props.value ? "text-primary" : "text-az-body" }`} > - {option.label} + {option.label} - {option.hint} + {option.hint} )} diff --git a/apps/gui/frontend/src/components/StatusDot.tsx b/apps/gui/frontend/src/components/StatusDot.tsx index 190fecb5d..e36ef73bb 100644 --- a/apps/gui/frontend/src/components/StatusDot.tsx +++ b/apps/gui/frontend/src/components/StatusDot.tsx @@ -106,16 +106,16 @@ export function ItemMarker(props: { status: ProjectStatus }): JSX.Element { return ( } + fallback={} > } + fallback={} > + } > diff --git a/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx b/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx index c9c214f55..6eb2ca33d 100644 --- a/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx +++ b/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx @@ -93,7 +93,7 @@ export function AnalyticsTab(): JSX.Element { +
{tx("Loading usage…")}
} @@ -108,6 +108,7 @@ export function AnalyticsTab(): JSX.Element { /> setActiveTab(key as AnalyticsTabKey)} class="gap-0" @@ -123,10 +124,10 @@ export function AnalyticsTab(): JSX.Element { {tx(tab.label)} @@ -177,15 +178,15 @@ function ItemBreakdown(props: { items: UsageItem[] }): JSX.Element { return (
-

{tx("Per item")}

- +

{tx("Per item")}

+ {tx("measured agent-active time from captured runs")}
0} fallback={ -
+
{tx("No item-linked runs yet")}
} @@ -195,20 +196,20 @@ function ItemBreakdown(props: { items: UsageItem[] }): JSX.Element { {(item) => (
-
+
{item.itemTitle}
-
+
{item.projectName} · {item.agents.join(", ") || "—"} · {item.turns}{" "} {tx("turns")} {item.completed ? ` · ${tx("finished")}` : ""}
-
+
{agentTime(item.durationMs)}
-
{tx("agent time")}
+
{tx("agent time")}
)} @@ -225,8 +226,8 @@ function AgentValue(props: { agents: UsageAgentValue[] }): JSX.Element { 0}>
-

{tx("Outcome per dollar")}

- +

{tx("Outcome per dollar")}

+ {tx("captured completions and attributed turns only")}
@@ -235,24 +236,24 @@ function AgentValue(props: { agents: UsageAgentValue[] }): JSX.Element { {(agent) => (
- + {agent.agent === "codex" ? "Codex" : agent.agent === "claude" ? "Claude" : agent.agent} - + {agent.costPerCompletedItem === null ? "—" : `${dollars(agent.costPerCompletedItem)} / ${tx("finished item")}`}
-
+
{agent.completedItems} {tx("finished")} · {agent.turns} {tx("turns")} ·{" "} {tokens(agent.processedTokens)} {tx("processed")}
-
+
{dollars(agent.effectiveCostUsd)} {tx("effective cost")} {agent.estimatedCostUsd > 0 ? ` · ${tx("includes local estimates")}` : ""}
@@ -271,21 +272,23 @@ function SessionBreakdown(props: { sessions: UsageSession[] }): JSX.Element { 0}>
-

{tx("Per session")}

- {tx("captured from this build onward")} +

{tx("Per session")}

+ + {tx("captured from this build onward")} +
{(session) => (
-
+
{session.projectName} - {session.agent} - + {session.agent} + {dollars(session.costUsd)}
-
+
{session.sessionId ? session.sessionId.slice(0, 8) : tx("no session id")} @@ -317,18 +320,18 @@ function SessionBreakdown(props: { sessions: UsageSession[] }): JSX.Element { function ProjectBreakdown(props: { projects: UsageProject[]; total: number }): JSX.Element { return (
-

{tx("Per project")}

+

{tx("Per project")}

{(project) => { const share = () => (project.costUsd / Math.max(props.total, 0.000001)) * 100; return (
-
+
{project.projectName} - + {project.turns} {tx("turns")} @@ -338,7 +341,7 @@ function ProjectBreakdown(props: { projects: UsageProject[]; total: number }): J
-
+
{share().toFixed(1)}% {tx("in")} {tokens(project.inputTokens)} · {tx("out")}{" "} @@ -472,10 +475,10 @@ function HeadlineRow(props: { {tx("Analytics refresh generation {count}", { count: props.refreshGeneration })}
-

+

{tx("Analytics")}

-
+
{tx("usage ledger")} 0}> @@ -488,6 +491,7 @@ function HeadlineRow(props: {
@@ -505,8 +512,8 @@ function HeadlineRow(props: { class="flex min-w-0 flex-col justify-center rounded-lg border border-az-hairline bg-az-inset px-2.5 py-2" title={tile.title} > -
{tile.label}
-
+
{tile.label}
+
{tile.value}
{/* @@ -523,7 +530,7 @@ function HeadlineRow(props: { footnote that can only be read by hovering is not a footnote. */} -
+
{tile.detail}
@@ -558,14 +565,14 @@ function DaySeries(props: { days: UsageDay[] }): JSX.Element { return (
-

{tx("Per day")}

+

{tx("Per day")}

{(day) => (
- + {day.day.slice(5)}
@@ -579,7 +586,7 @@ function DaySeries(props: { days: UsageDay[] }): JSX.Element { )}
- + {dollars(day.costUsd)}
@@ -602,7 +609,7 @@ function Legend(): JSX.Element {
{(cls) => ( - + {label[cls.key]} @@ -616,9 +623,9 @@ function Legend(): JSX.Element { function ModelBreakdown(props: { models: UsageModel[] }): JSX.Element { return (
-

{tx("Per model")}

+

{tx("Per model")}

-
+
{tx("Model")} {tx("Cost")} {tx("Input")} @@ -628,7 +635,7 @@ function ModelBreakdown(props: { models: UsageModel[] }): JSX.Element {
{(model) => ( -
+
{model.model} {dollars(model.costUsd)} {tokens(model.inputTokens)} diff --git a/apps/gui/frontend/src/features/draft/DraftTab.tsx b/apps/gui/frontend/src/features/draft/DraftTab.tsx index cde9047b7..6f7129bb9 100644 --- a/apps/gui/frontend/src/features/draft/DraftTab.tsx +++ b/apps/gui/frontend/src/features/draft/DraftTab.tsx @@ -27,8 +27,9 @@ export function DraftTab(props: { tab: Tab }): JSX.Element { effort controls a long way from the posture controls they sit beside in every other prompt area and left a large empty span between them. */} -
+
- {tx("Projects")} - + {tx("Projects")} + {tx("and their items · click a project to open its tab")} @@ -130,8 +130,9 @@ export function HomeTab(): JSX.Element {
- + { @@ -140,9 +141,9 @@ export function HomeTab(): JSX.Element { }} placeholder={tx("Search projects and items…")} aria-label={tx("Search projects and items")} - class="min-w-0 flex-1 bg-transparent text-[12.5px] text-base-content placeholder:text-az-muted focus:outline-none" + class="min-w-0 flex-1 bg-transparent text-base-content text-ui-label-lg placeholder:text-az-muted focus:outline-none" /> - + ⌘K
@@ -162,15 +163,19 @@ export function HomeTab(): JSX.Element { )} -

+

{tx("Nothing matches “{query}”", { query: query() })}

0}> togglePanelSection("pinned")} class="flex-none" > -
- - {(project) => ( - - )} - -
+ {() => ( +
+ + {(project) => ( + + )} + +
+ )}
togglePanelSection("recent")} class={prefs.panelSections.recent ? "flex min-h-0 flex-1 flex-col" : "flex-none"} > -
- - {(project) => ( + {() => ( +
+ + {(project) => ( + + )} + + - )} - - - - -
+ +
+ )}
{/* @@ -291,6 +315,7 @@ export function HomeTab(): JSX.Element { */} 0}> - + {() => }
@@ -328,6 +353,7 @@ export function CleanupRowActions(props: { return (
{tx("Delete")} @@ -382,9 +409,10 @@ function HomeItemSortControls(): JSX.Element { aria-label={tx("Sort projects and items")} > @@ -456,6 +485,7 @@ function HomeCleanupButton(): JSX.Element { return ( ); @@ -595,16 +625,17 @@ function TaskManagerComposer(): JSX.Element { role="alert" class="mb-2 flex items-center gap-3 rounded-[11px] border border-error/38 bg-error/8 px-3 py-2.5" > - -

+ +

{tx( "The task manager cannot send prompts until its selected agent is installed, compatible, and signed in.", )}

@@ -617,7 +648,7 @@ function TaskManagerComposer(): JSX.Element { > @@ -625,6 +656,7 @@ function TaskManagerComposer(): JSX.Element { when={tall()} fallback={ setDraft(event.currentTarget.value)} onKeyDown={(event) => { @@ -637,11 +669,12 @@ function TaskManagerComposer(): JSX.Element { placeholder={placeholder()} aria-label={tx("Task manager prompt")} disabled={isSending() || waitsForRun() || !agentReady()} - class="min-w-0 flex-1 bg-transparent text-[12.5px] text-base-content placeholder:text-az-muted focus:outline-none disabled:opacity-60" + class="min-w-0 flex-1 bg-transparent text-base-content text-ui-label-lg placeholder:text-az-muted focus:outline-none disabled:opacity-60" /> } >