From 2fae8489c05851fa36cbba3ad0716f8943c4c15a Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 12:15:45 +0100 Subject: [PATCH] feat(browser,console): ship the browser page + renderers as injectable console UI --- browser/Cargo.lock | 15 +- browser/Cargo.toml | 3 +- browser/build.rs | 173 ++++ browser/src/lib.rs | 1 + browser/src/main.rs | 4 + browser/src/ui.rs | 76 ++ browser/ui/build.mjs | 37 + browser/ui/package.json | 19 + browser/ui/page.tsx | 33 + .../BrowserViews.tsx | 157 ++-- .../src/function-trigger-message}/index.tsx | 106 +-- .../src/function-trigger-message}/parsers.ts | 2 +- .../web => browser/ui}/src/lib/browser.ts | 122 +-- browser/ui/src/lib/cn.ts | 9 + browser/ui/src/lib/errors.tsx | 377 ++++++++ browser/ui/src/lib/events.ts | 191 ++++ browser/ui/src/lib/format.ts | 20 + browser/ui/src/lib/highlight.tsx | 79 ++ browser/ui/src/lib/icons.tsx | 126 +++ browser/ui/src/lib/shared.tsx | 55 ++ .../ui/src/page}/ConsolePanel.tsx | 34 +- .../ui/src/page}/NetworkPanel.tsx | 63 +- browser/ui/src/page/SessionRail.tsx | 70 ++ .../ui/src/page}/SessionView.tsx | 158 ++-- .../ui/src/page}/Viewport.tsx | 45 +- browser/ui/src/page/index.tsx | 158 ++++ .../ui/src/page}/useBrowserSessionsLive.ts | 25 +- .../ui/src/page}/useLiveFrames.ts | 21 +- browser/ui/styles.css | 863 ++++++++++++++++++ browser/ui/tsconfig.json | 14 + console/web/src/App.tsx | 23 +- console/web/src/components/chat/ChatView.tsx | 54 -- .../function-trigger/renderer-registry.tsx | 19 +- console/web/src/hooks/use-browser-events.ts | 227 ----- console/web/src/hooks/use-browser-status.ts | 43 - console/web/src/hooks/use-hash-route.ts | 66 +- console/web/src/lib/conversations-context.tsx | 14 - console/web/src/lib/nav-options.test.ts | 47 +- console/web/src/lib/nav-options.ts | 4 - .../pages/Browser/components/SessionRail.tsx | 77 -- console/web/src/pages/Browser/index.tsx | 198 ---- pnpm-lock.yaml | 29 +- pnpm-workspace.yaml | 1 + 43 files changed, 2719 insertions(+), 1139 deletions(-) create mode 100644 browser/src/ui.rs create mode 100644 browser/ui/build.mjs create mode 100644 browser/ui/package.json create mode 100644 browser/ui/page.tsx rename {console/web/src/components/chat/browser => browser/ui/src/function-trigger-message}/BrowserViews.tsx (70%) rename {console/web/src/components/chat/browser => browser/ui/src/function-trigger-message}/index.tsx (60%) rename {console/web/src/components/chat/browser => browser/ui/src/function-trigger-message}/parsers.ts (99%) rename {console/web => browser/ui}/src/lib/browser.ts (85%) create mode 100644 browser/ui/src/lib/cn.ts create mode 100644 browser/ui/src/lib/errors.tsx create mode 100644 browser/ui/src/lib/events.ts create mode 100644 browser/ui/src/lib/format.ts create mode 100644 browser/ui/src/lib/highlight.tsx create mode 100644 browser/ui/src/lib/icons.tsx create mode 100644 browser/ui/src/lib/shared.tsx rename {console/web/src/pages/Browser/components => browser/ui/src/page}/ConsolePanel.tsx (76%) rename {console/web/src/pages/Browser/components => browser/ui/src/page}/NetworkPanel.tsx (59%) create mode 100644 browser/ui/src/page/SessionRail.tsx rename {console/web/src/pages/Browser/components => browser/ui/src/page}/SessionView.tsx (64%) rename {console/web/src/pages/Browser/components => browser/ui/src/page}/Viewport.tsx (85%) create mode 100644 browser/ui/src/page/index.tsx rename {console/web/src/pages/Browser/hooks => browser/ui/src/page}/useBrowserSessionsLive.ts (71%) rename {console/web/src/pages/Browser/hooks => browser/ui/src/page}/useLiveFrames.ts (87%) create mode 100644 browser/ui/styles.css create mode 100644 browser/ui/tsconfig.json delete mode 100644 console/web/src/hooks/use-browser-events.ts delete mode 100644 console/web/src/hooks/use-browser-status.ts delete mode 100644 console/web/src/pages/Browser/components/SessionRail.tsx delete mode 100644 console/web/src/pages/Browser/index.tsx diff --git a/browser/Cargo.lock b/browser/Cargo.lock index dcccb14a8..4ac3236ad 100644 --- a/browser/Cargo.lock +++ b/browser/Cargo.lock @@ -133,7 +133,7 @@ dependencies = [ [[package]] name = "browser" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "arc-swap", @@ -142,6 +142,7 @@ dependencies = [ "chromiumoxide", "clap", "futures", + "iii-console-ui", "iii-sdk", "regex", "schemars", @@ -807,6 +808,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" diff --git a/browser/Cargo.toml b/browser/Cargo.toml index 9ce31e218..970189a98 100644 --- a/browser/Cargo.toml +++ b/browser/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "browser" -version = "0.1.4" +version = "0.1.5" edition = "2021" publish = false @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui" } arc-swap = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "process"] } serde = { version = "1", features = ["derive"] } diff --git a/browser/build.rs b/browser/build.rs index 81caa36d6..35228f339 100644 --- a/browser/build.rs +++ b/browser/build.rs @@ -1,6 +1,179 @@ +//! Build script for the `browser` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the state worker's precedent). Set `SKIP_UI_BUILD=1` to use +//! the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + fn main() { println!( "cargo:rustc-env=TARGET={}", std::env::var("TARGET").unwrap() ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); } diff --git a/browser/src/lib.rs b/browser/src/lib.rs index 9a8feb1f3..8b3f763e5 100644 --- a/browser/src/lib.rs +++ b/browser/src/lib.rs @@ -9,3 +9,4 @@ pub mod functions; pub mod manifest; pub mod session; pub mod snapshot; +pub mod ui; diff --git a/browser/src/main.rs b/browser/src/main.rs index 62ee7574b..f9273e8fb 100644 --- a/browser/src/main.rs +++ b/browser/src/main.rs @@ -141,6 +141,10 @@ async fn main() -> Result<()> { configuration::register_config_trigger(&iii, shared.clone()) .context("registering configuration change trigger")?; + // Injectable console UI — after the browser::* functions so the console + // can attribute the assets. + browser::ui::register(&iii); + // Idle sweep: stop sessions nobody has touched for idle_stop_ms. let sweep_sessions = sessions.clone(); let sweep = tokio::spawn(async move { diff --git a/browser/src/ui.rs b/browser/src/ui.rs new file mode 100644 index 000000000..5da1b147e --- /dev/null +++ b/browser/src/ui.rs @@ -0,0 +1,76 @@ +//! Injectable console UI for the browser worker +//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP: +//! workers/docs/sops/injectable-console-ui.md). +//! +//! Ships two assets into any running console: +//! +//! - `browser/page.js` (`console:script`) — the `#/ext/browser` page (session +//! rail, screencast-fed live viewport, console/network feeds) AND the +//! function-trigger renderer for every `browser::*` call in chat and the +//! traces span tab. +//! - `browser/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="browser"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `browser::ui-content`, one +//! Message-path trigger per asset, `III_BROWSER_UI_WATCH` hot-reload watcher) +//! lives in the shared `iii-console-ui` crate (path-linked from +//! `workers/crates/console-ui`); this module only names the assets and embeds +//! their bytes. +//! +//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui +//! external — they resolve through the console's import map at runtime) and +//! embedded at compile time so the worker stays one self-contained binary. +//! For the dev loop, set `III_BROWSER_UI_WATCH` to the build output directory +//! (or `1` for `ui/dist`): the worker polls both files and re-registers a +//! changed asset's trigger — every open console tab hot-swaps it. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "browser/page.js"; +pub const STYLES_PATH: &str = "browser/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("browser") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the browser worker's console UI. Call after the `browser::*` +/// functions are registered. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=browser]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="browser"]"#) + || STYLES_CSS.contains("[data-iii-ui=browser]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/browser/ui/build.mjs b/browser/ui/build.mjs new file mode 100644 index 000000000..3e41e541a --- /dev/null +++ b/browser/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime + * through the console's import map (a bundled React copy would surface as + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_BROWSER_UI_WATCH + * poller for the hot-reload dev loop. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/browser/ui/package.json b/browser/ui/package.json new file mode 100644 index 000000000..2c18f7df1 --- /dev/null +++ b/browser/ui/package.json @@ -0,0 +1,19 @@ +{ + "name": "@iii-workers/browser-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/browser/ui/page.tsx b/browser/ui/page.tsx new file mode 100644 index 000000000..100e62053 --- /dev/null +++ b/browser/ui/page.tsx @@ -0,0 +1,33 @@ +/** + * Entry for the browser worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/page.js and served over + * the `console:script` trigger (see src/ui.rs). The stylesheet is its own + * asset: ../styles.css ships over `console:style` as browser/styles.css — + * the console mounts and link-swaps it, styles-before-scripts on boot. + * + * `setup(host)` registers two contributions: + * - src/page/ — the `#/ext/browser` page: the session rail, a screencast-fed + * live viewport, and the console/network feeds for the selected session. + * - src/function-trigger-message/ — how every `browser::*` call renders in + * chat and the traces span tab (per-function terminal cards). + * + * No config form is registered: the browser worker's configuration is plain + * scalar fields, so the console's schema-generated form is sufficient. + * + * Registrations go through `host` so the loader disposes them on hot reload / + * worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { createBrowserRenderer } from './src/function-trigger-message' +import { BrowserPage } from './src/page' + +export default function setup(host: Host) { + host.pages.register({ + id: 'browser', + title: 'browser', + render: () => , + }) + + host.functionTriggers.register(createBrowserRenderer(host)) +} diff --git a/console/web/src/components/chat/browser/BrowserViews.tsx b/browser/ui/src/function-trigger-message/BrowserViews.tsx similarity index 70% rename from console/web/src/components/chat/browser/BrowserViews.tsx rename to browser/ui/src/function-trigger-message/BrowserViews.tsx index e79c11152..d8e058101 100644 --- a/console/web/src/components/chat/browser/BrowserViews.tsx +++ b/browser/ui/src/function-trigger-message/BrowserViews.tsx @@ -1,21 +1,15 @@ +import { Badge, JsonHighlight } from '@iii-dev/console-ui' import { z } from 'zod' -import { renderWithHighlight } from '@/components/chat/sandbox/highlight' -import { - ActionLine, - Chip, - MetaRow, - StatusPill, -} from '@/components/chat/sandbox/shared' -import { Badge } from '@/components/ui/Badge' import { type BrowserConsoleEntry, type BrowserNetworkEntry, elementLabel, formatTime, levelBadgeVariant, -} from '@/lib/browser' -import { JsonHighlight } from '@/lib/syntax' -import { cn } from '@/lib/utils' +} from '../lib/browser' +import { cn } from '../lib/cn' +import { renderWithHighlight } from '../lib/highlight' +import { ActionLine, Chip, MetaRow, StatusPill } from '../lib/shared' import { actResultSchema, consoleReadSchema, @@ -53,7 +47,7 @@ function truncate(s: string, max: number): string { * the shared grep-style match highlighter. */ function SnapshotTree({ tree }: { tree: string }) { return ( -
+    
       
         {renderWithHighlight(tree, '\\[ref=[^\\]]*\\]', {
           isRegex: true,
@@ -75,7 +69,7 @@ export function SnapshotView({ output }: { output: unknown }) {
         {res.truncated ?  : null}
       
       
-        {res.url}
+        {res.url}
       
       
     
@@ -95,7 +89,7 @@ export function SessionStartView({ output }: { output: unknown }) {
         {res.headless ? 'headless' : 'headful'}
       
       
-        {res.url}
+        {res.url}
       
     
   )
@@ -126,25 +120,20 @@ export function SessionListView({ output }: { output: unknown }) {
         />
       
       {res.sessions.length === 0 ? (
-        
- · no live sessions -
+
· no live sessions
) : ( - +
{res.sessions.map((s) => ( - - + - - + - @@ -172,7 +161,7 @@ export function NavigateView({ output }: { output: unknown }) { {res.title ? {truncate(res.title, 60)} : null} - {res.url} + {res.url} ) @@ -204,10 +193,10 @@ export function ActView({ variant={res.ok ? 'accent' : 'alert'} /> {req?.action ? {req.action} : null} - {req?.ref ? {req.ref} : null} + {req?.ref ? {req.ref} : null} {req?.key ? {req.key} : null} {req?.x != null && req?.y != null ? ( - + {Math.round(req.x)},{Math.round(req.y)} ) : null} @@ -243,7 +232,7 @@ export function HistoryView({ ) : null} - {res.url} + {res.url} ) @@ -261,20 +250,15 @@ const readInputSchema = z.object({ export function ConsoleEntryRow({ entry }: { entry: BrowserConsoleEntry }) { return ( -
  • - - {formatTime(entry.timestamp)} - - +
  • + {formatTime(entry.timestamp)} + {entry.level} - + {entry.text} {entry.source ? ( - · {entry.source} + · {entry.source} ) : null}
  • @@ -301,17 +285,13 @@ export function ConsoleReadView({ {req?.level ? {req.level} : null} {req?.pattern ? /{req.pattern}/ : null} {res.dropped > 0 ? ( - - {res.dropped} dropped - + {res.dropped} dropped ) : null} {res.entries.length === 0 ? ( -
    - · no matching console entries -
    +
    · no matching console entries
    ) : ( -
      +
        {res.entries.map((entry) => ( ))} @@ -323,25 +303,17 @@ export function ConsoleReadView({ export function NetworkEntryRow({ entry }: { entry: BrowserNetworkEntry }) { return ( -
      • +
      • {entry.status ?? (entry.failed ? 'err' : '...')} - {entry.method} - + {entry.method} + {entry.url} {entry.error ? ( - · {entry.error} + · {entry.error} ) : null}
      • @@ -366,21 +338,17 @@ export function NetworkReadView({ variant={res.entries.length > 0 ? 'accent' : 'default'} /> {req?.failed_only ? ( - failed only + failed only ) : null} {req?.pattern ? /{req.pattern}/ : null} {res.dropped > 0 ? ( - - {res.dropped} dropped - + {res.dropped} dropped ) : null} {res.entries.length === 0 ? ( -
        - · no matching requests -
        +
        · no matching requests
        ) : ( -
          +
            {res.entries.map((entry) => ( ))} @@ -402,29 +370,24 @@ export function StylesReadView({ output }: { output: unknown }) { label={`${res.properties.length} properties`} variant="accent" /> - {res.ref} + {res.ref} -
            -
    +
    {s.session_id} {s.url} + {s.url} {s.headless ? 'headless' : 'headful'} + {s.console_entries} logs
    +
    +
    {res.properties.map((prop) => ( - - + - + ))}
    +
    {prop.name} {prop.value}{prop.value}
    {res.inline_style ? ( -
    - style="{res.inline_style}" -
    +
    style="{res.inline_style}"
    ) : null} ) @@ -453,16 +416,14 @@ export function StylesWriteView({ label={res.ok ? 'applied' : 'failed'} variant={res.ok ? 'accent' : 'alert'} /> - {req?.ref ? {req.ref} : null} + {req?.ref ? {req.ref} : null} {req?.property ? ( {req.property}: {req.value ?? ''} ) : null} -
    - style="{res.inline_style}" -
    +
    style="{res.inline_style}"
    ) } @@ -490,25 +451,25 @@ export function DomReadView({ output }: { output: unknown }) { {res.truncated ? : null} -
    +
    {rows.map(({ node, depth }) => (
    {node.tag === '#text' ? ( - + "{truncate(node.text ?? '', 80)}" ) : ( - + {elementLabel(node.tag, node.id, node.classes)} )}{' '} - [{node.ref}] + [{node.ref}] {node.child_count > node.children.length ? ( - + {' '} +{node.child_count - node.children.length} more @@ -544,25 +505,19 @@ export function EvaluateView({ {req?.expression ? ( - {truncate(req.expression, 200)} + {truncate(req.expression, 200)} ) : null} {res.ok ? ( res.value === undefined ? ( -
    - · undefined -
    +
    · undefined
    ) : ( -
    - +
    +
    ) ) : ( -
    - {res.error ?? 'evaluation failed'} -
    +
    {res.error ?? 'evaluation failed'}
    )}
    ) diff --git a/console/web/src/components/chat/browser/index.tsx b/browser/ui/src/function-trigger-message/index.tsx similarity index 60% rename from console/web/src/components/chat/browser/index.tsx rename to browser/ui/src/function-trigger-message/index.tsx index c39c4ed92..d405de7fe 100644 --- a/console/web/src/components/chat/browser/index.tsx +++ b/browser/ui/src/function-trigger-message/index.tsx @@ -1,14 +1,26 @@ -import { SquareArrowOutUpRight } from 'lucide-react' -import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' -import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' -import { hashForBrowserSession } from '@/hooks/use-hash-route' +/** + * Injected function-trigger renderer for every `browser::*` call — + * registered through `host.functionTriggers`, so it dispatches BEFORE the + * console's built-in families and owns how browser calls render in chat and + * in the traces span tab. Ported from the console's `components/chat/browser` + * family; the "open in browser tab" link now points at the injected page + * (`#/ext/browser`), and shared-infra errors render through the ported + * `InfraErrorView` instead of the sandbox family. + */ + +import { + type FunctionTriggerMessage, + type FunctionTriggerRenderer, + type Host, + JsonHighlight, +} from '@iii-dev/console-ui' import { browserSessionIdFromCall, isBrowserFunction, parseScreenshotOutput, -} from '@/lib/browser' -import { JsonHighlight } from '@/lib/syntax' -import type { FunctionTriggerMessage } from '@/types/chat' +} from '../lib/browser' +import { InfraErrorView, parseInfraErrorDisplay } from '../lib/errors' +import { ExternalLink } from '../lib/icons' import { ActView, ConsoleReadView, @@ -26,19 +38,22 @@ import { } from './BrowserViews' import { decodeBrowserResult } from './parsers' +/** The injected page's route — where "open in browser tab" navigates. */ +const BROWSER_PAGE_HASH = '#/ext/browser' + /** * Header label for `browser::*` ids: dims the namespace prefix so the op - * (`navigate`, `act`, …) reads clearly. Mirrors `StateFunctionIdLabel`. + * (`navigate`, `act`, …) reads clearly. */ -export function BrowserFunctionIdLabel({ functionId }: { functionId: string }) { +function FunctionIdLabel({ functionId }: { functionId: string }) { if (!functionId.startsWith('browser::')) { - return {functionId} + return {functionId} } const tail = functionId.slice('browser::'.length) return ( <> - browser:: - {tail} + browser:: + {tail} ) } @@ -55,11 +70,11 @@ function ScreenshotBody({ output }: { output: unknown }) { const screenshot = parseScreenshotOutput(output) if (!screenshot?.dataUrl) return null return ( -
    +
    {`capture
    ) @@ -109,61 +124,51 @@ function renderBody(message: FunctionTriggerMessage): React.ReactNode | null { /** * Terminal view for a `browser::*` call: the owning session with an - * "open in browser tab" affordance (routes to `#/browser/`), - * then the per-function pretty body. Unknown or unparseable payloads fall - * back to the decoded result as clamped JSON; the raw json tab keeps the - * untouched request/response panes. + * "open in browser tab" affordance (routes to `#/ext/browser`), then the + * per-function pretty body. Unknown or unparseable payloads fall back to the + * decoded result as clamped JSON. */ function BrowserCallView({ message }: { message: FunctionTriggerMessage }) { const sessionId = browserSessionIdFromCall(message.input, message.output) const running = !!message.running const body = !running && message.output != null ? renderBody(message) : null - // Fallback decode only when no pretty body renders (the normal case has a - // body, so the full envelope parse is skipped there). const fallback = !body && message.output != null ? decodeBrowserResult(message.output) : null return ( -
    -
    +
    +
    {sessionId ? ( - - session {sessionId} + + session {sessionId} ) : ( - browser + browser )} {sessionId ? ( - - + + open in browser tab ) : null}
    {running && message.output == null ? ( -

    - running... -

    +

    running...

    ) : body ? ( body ) : fallback != null ? ( -
    +
    ) : ( -

    - no result -

    +

    no result

    )}
    ) } -function tryRender(message: FunctionTriggerMessage): React.ReactNode | null { +function renderCall(message: FunctionTriggerMessage): React.ReactNode | null { if (!isBrowserFunction(message.functionId)) return null if (message.pendingApproval) return null @@ -173,23 +178,20 @@ function tryRender(message: FunctionTriggerMessage): React.ReactNode | null { // envelopes). Browser success payloads never look denial-shaped: results // are `ok`-flagged structs or `{content, details}` envelopes. const errorDisplay = - !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + !running && rawOutput != null ? parseInfraErrorDisplay(rawOutput) : null if (errorDisplay) { - return + return } return } -/** `browser::*` calls have no bespoke pending preview. */ -function tryRenderPreview( - _message: FunctionTriggerMessage, -): React.ReactNode | null { - return null -} - -export const BrowserToolView = { - isBrowserFunction, - tryRender, - tryRenderRunning: tryRender, - tryRenderPreview, +export function createBrowserRenderer(_host: Host): FunctionTriggerRenderer { + return { + id: 'browser/page.js#calls', + isMatch: isBrowserFunction, + tryRender: (message) => renderCall(message), + tryRenderRunning: (message) => renderCall(message), + tryRenderPreview: () => null, + FunctionIdLabel, + } } diff --git a/console/web/src/components/chat/browser/parsers.ts b/browser/ui/src/function-trigger-message/parsers.ts similarity index 99% rename from console/web/src/components/chat/browser/parsers.ts rename to browser/ui/src/function-trigger-message/parsers.ts index eed9f4627..973449f3b 100644 --- a/console/web/src/components/chat/browser/parsers.ts +++ b/browser/ui/src/function-trigger-message/parsers.ts @@ -5,7 +5,7 @@ import { networkReadSchema, sessionInfoSchema, sessionStartSchema, -} from '@/lib/browser' +} from '../lib/browser' /** * Zod schemas + decode helpers for the `browser::*` chat views. diff --git a/console/web/src/lib/browser.ts b/browser/ui/src/lib/browser.ts similarity index 85% rename from console/web/src/lib/browser.ts rename to browser/ui/src/lib/browser.ts index 2492e3601..d73cd97f0 100644 --- a/console/web/src/lib/browser.ts +++ b/browser/ui/src/lib/browser.ts @@ -1,13 +1,15 @@ +import type { ExtensionIii } from '@iii-dev/console-ui' import { z } from 'zod' -import { getIiiClient } from '@/lib/iii-client' /** - * Control plane for the optional `browser` worker: typed wrappers over its - * session surface (start / list / stop / navigate / screenshot / act / - * console / network / pick) plus parsers and formatting helpers for the - * Browser page, the chat function-trigger view, and the pick-to-chat flow. - * Everything here is gated by the caller on browser presence - * (`use-browser-status`). + * Control plane for the `browser` worker's own injected UI: typed wrappers + * over its session surface (start / list / stop / navigate / screenshot / act + * / console / network / pick) plus parsers and formatting helpers for the + * page, the chat function-trigger views, and the pick-to-clipboard flow. + * + * Every call goes through the tab's `host.iii` client (an `ExtensionIii`), + * passed in by the page — there is no module-level singleton in injected UI. + * Wire source: workers/browser/src/functions/*.rs (verbatim ids + payloads). */ export const BROWSER_SESSIONS_START_FUNCTION_ID = 'browser::sessions::start' @@ -34,7 +36,7 @@ export const BROWSER_NETWORK_EVENT_TRIGGER = 'browser::network-event' export const BROWSER_PICKED_TRIGGER = 'browser::picked' /** Stream the worker pushes live viewport frames onto (group = session id). - * The console subscribes with a `type:'stream'` trigger instead of polling. */ + * The page subscribes with a `type:'stream'` trigger instead of polling. */ export const BROWSER_FRAMES_STREAM = 'browser:frames' /** Session lifecycle trigger types the sessions rail re-reads on. */ @@ -390,9 +392,9 @@ export function pickedSelector(element: BrowserPickedElement): string { } /** - * Compact text block handed to the chat composer for a picked element: - * one summary line, the url, recent console errors when present, and the - * ref the agent can use directly. Never includes outer_html. + * Compact text block copied to the clipboard for a picked element: one + * summary line, the url, recent console errors when present, and the ref the + * agent can use directly. Never includes outer_html. */ export function formatPickedElement(evt: BrowserPickedEvent): string { const el = evt.element @@ -414,12 +416,10 @@ export function formatPickedElement(evt: BrowserPickedEvent): string { return lines.join('\n') } -export async function listBrowserSessions(): Promise { - const client = await getIiiClient() - const res = await client.trigger( - BROWSER_SESSIONS_LIST_FUNCTION_ID, - {}, - ) +export async function listBrowserSessions( + iii: ExtensionIii, +): Promise { + const res = await iii.trigger(BROWSER_SESSIONS_LIST_FUNCTION_ID, {}) const parsed = sessionListSchema.safeParse(res) if (!parsed.success) return [] return (parsed.data.sessions ?? []) @@ -431,10 +431,10 @@ export async function listBrowserSessions(): Promise { } export async function startBrowserSession( + iii: ExtensionIii, url?: string, ): Promise { - const client = await getIiiClient() - const res = await client.trigger( + const res = await iii.trigger( BROWSER_SESSIONS_START_FUNCTION_ID, url ? { url } : {}, ) @@ -442,29 +442,31 @@ export async function startBrowserSession( return parsed.success ? parsed.data : null } -export async function stopBrowserSession(sessionId: string): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_SESSIONS_STOP_FUNCTION_ID, { +export async function stopBrowserSession( + iii: ExtensionIii, + sessionId: string, +): Promise { + await iii.trigger(BROWSER_SESSIONS_STOP_FUNCTION_ID, { session_id: sessionId, }) } export async function navigateBrowser( + iii: ExtensionIii, sessionId: string, url: string, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_NAVIGATE_FUNCTION_ID, { + await iii.trigger(BROWSER_NAVIGATE_FUNCTION_ID, { session_id: sessionId, url, }) } export async function takeBrowserScreenshot( + iii: ExtensionIii, sessionId: string, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(BROWSER_SCREENSHOT_FUNCTION_ID, { + const res = await iii.trigger(BROWSER_SCREENSHOT_FUNCTION_ID, { session_id: sessionId, }) return parseScreenshotOutput(res) @@ -476,13 +478,13 @@ export interface BrowserClickOptions { } export async function clickBrowserAt( + iii: ExtensionIii, sessionId: string, x: number, y: number, options: BrowserClickOptions = {}, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_ACT_FUNCTION_ID, { + await iii.trigger(BROWSER_ACT_FUNCTION_ID, { session_id: sessionId, action: 'click', x, @@ -493,13 +495,13 @@ export async function clickBrowserAt( } export async function scrollBrowserAt( + iii: ExtensionIii, sessionId: string, x: number, y: number, deltaY: number, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_ACT_FUNCTION_ID, { + await iii.trigger(BROWSER_ACT_FUNCTION_ID, { session_id: sessionId, action: 'scroll', x, @@ -509,11 +511,11 @@ export async function scrollBrowserAt( } export async function typeBrowserText( + iii: ExtensionIii, sessionId: string, text: string, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_ACT_FUNCTION_ID, { + await iii.trigger(BROWSER_ACT_FUNCTION_ID, { session_id: sessionId, action: 'type', text, @@ -521,11 +523,11 @@ export async function typeBrowserText( } export async function pressBrowserKey( + iii: ExtensionIii, sessionId: string, key: string, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_ACT_FUNCTION_ID, { + await iii.trigger(BROWSER_ACT_FUNCTION_ID, { session_id: sessionId, action: 'press', key, @@ -533,12 +535,12 @@ export async function pressBrowserKey( } export async function hintBrowserPick( + iii: ExtensionIii, sessionId: string, x: number, y: number, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(BROWSER_PICK_HINT_FUNCTION_ID, { + const res = await iii.trigger(BROWSER_PICK_HINT_FUNCTION_ID, { session_id: sessionId, x, y, @@ -557,16 +559,20 @@ const frameSchema = z.object({ }) export type BrowserFrame = z.infer -export async function startBrowserScreencast(sessionId: string): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_SCREENCAST_START_FUNCTION_ID, { +export async function startBrowserScreencast( + iii: ExtensionIii, + sessionId: string, +): Promise { + await iii.trigger(BROWSER_SCREENCAST_START_FUNCTION_ID, { session_id: sessionId, }) } -export async function stopBrowserScreencast(sessionId: string): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_SCREENCAST_STOP_FUNCTION_ID, { +export async function stopBrowserScreencast( + iii: ExtensionIii, + sessionId: string, +): Promise { + await iii.trigger(BROWSER_SCREENCAST_STOP_FUNCTION_ID, { session_id: sessionId, }) } @@ -576,11 +582,11 @@ export async function stopBrowserScreencast(sessionId: string): Promise { * poll fast. `frame` is absent while `sinceFrame` is still the newest seq. */ export async function readBrowserFrame( + iii: ExtensionIii, sessionId: string, sinceFrame?: number, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(BROWSER_FRAME_FUNCTION_ID, { + const res = await iii.trigger(BROWSER_FRAME_FUNCTION_ID, { session_id: sessionId, ...(sinceFrame != null ? { since_frame: sinceFrame } : {}), }) @@ -596,11 +602,11 @@ export interface BrowserConsoleReadOptions { } export async function readBrowserConsole( + iii: ExtensionIii, sessionId: string, options: BrowserConsoleReadOptions = {}, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(BROWSER_CONSOLE_READ_FUNCTION_ID, { + const res = await iii.trigger(BROWSER_CONSOLE_READ_FUNCTION_ID, { session_id: sessionId, ...(options.pattern ? { pattern: options.pattern } : {}), ...(options.level ? { level: options.level } : {}), @@ -619,11 +625,11 @@ export interface BrowserNetworkReadOptions { } export async function readBrowserNetwork( + iii: ExtensionIii, sessionId: string, options: BrowserNetworkReadOptions = {}, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(BROWSER_NETWORK_READ_FUNCTION_ID, { + const res = await iii.trigger(BROWSER_NETWORK_READ_FUNCTION_ID, { session_id: sessionId, ...(options.pattern ? { pattern: options.pattern } : {}), ...(options.failedOnly ? { failed_only: true } : {}), @@ -634,9 +640,11 @@ export async function readBrowserNetwork( return parsed.success ? parsed.data : null } -export async function startBrowserPick(sessionId: string): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_PICK_START_FUNCTION_ID, { +export async function startBrowserPick( + iii: ExtensionIii, + sessionId: string, +): Promise { + await iii.trigger(BROWSER_PICK_START_FUNCTION_ID, { session_id: sessionId, }) } @@ -645,21 +653,23 @@ export async function startBrowserPick(sessionId: string): Promise { * Deterministic (same getNodeForLocation hit-test as the hover hint), unlike * a synthesized click through DevTools inspect mode. */ export async function resolveBrowserPick( + iii: ExtensionIii, sessionId: string, x: number, y: number, ): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_PICK_RESOLVE_FUNCTION_ID, { + await iii.trigger(BROWSER_PICK_RESOLVE_FUNCTION_ID, { session_id: sessionId, x, y, }) } -export async function stopBrowserPick(sessionId: string): Promise { - const client = await getIiiClient() - await client.trigger(BROWSER_PICK_STOP_FUNCTION_ID, { +export async function stopBrowserPick( + iii: ExtensionIii, + sessionId: string, +): Promise { + await iii.trigger(BROWSER_PICK_STOP_FUNCTION_ID, { session_id: sessionId, }) } diff --git a/browser/ui/src/lib/cn.ts b/browser/ui/src/lib/cn.ts new file mode 100644 index 000000000..9f5134af4 --- /dev/null +++ b/browser/ui/src/lib/cn.ts @@ -0,0 +1,9 @@ +/** + * Minimal class-name joiner for the injected UI. The console's Tailwind + * `cn` (clsx + tailwind-merge) is not available here — injected UI ships its + * own scoped stylesheet with semantic classes, so there are no conflicting + * utility classes to merge. This just filters falsy values and space-joins. + */ +export function cn(...parts: Array): string { + return parts.filter(Boolean).join(' ') +} diff --git a/browser/ui/src/lib/errors.tsx b/browser/ui/src/lib/errors.tsx new file mode 100644 index 000000000..e211838fe --- /dev/null +++ b/browser/ui/src/lib/errors.tsx @@ -0,0 +1,377 @@ +/** + * Shared-infra error parsing + view for `browser::*` chat cards. Ported from + * the console's sandbox `parsers.ts` + `ErrorView.tsx` (the console rendered + * browser errors through `parseSandboxErrorDisplay` / `SandboxErrorView`). + * Normalises every failure shape the console may receive — flat Stripe-style + * wire errors, harness `{ content, details }` wrappers, denial envelopes, + * function_error envelopes, dispatch-policy denials, and JSON embedded in + * strings — into one display union. The exec-stream (S200) special case from + * the sandbox view is dropped: browser errors never carry an ExecResponse. + * Tailwind utility classes are replaced with scoped `br-ui-*` classes. + */ + +import { Badge } from '@iii-dev/console-ui' +import { z } from 'zod' + +const infraErrorWireSchema = z.object({ + type: z.string(), + code: z.string().regex(/^S\d{3}$/), + message: z.string(), + docs_url: z.string().optional(), + retryable: z.boolean().optional(), + fix: z.unknown().optional(), + fix_note: z.string().nullable().optional(), +}) +export type InfraErrorWire = z.infer + +const denialEnvelopeSchema = z.object({ + schema_version: z.number().optional(), + status: z.string().optional(), + denied_by: z.string().optional(), + function_id: z.string().optional(), + reason: z.string().optional(), +}) +type DenialEnvelopeWire = z.infer + +const functionErrorEnvelopeSchema = z.object({ + kind: z.string(), + message: z.string(), + details: z.unknown().optional(), + content: z.array(z.unknown()).optional(), +}) + +export interface InfraInvocationError { + title: string + message: string + functionId?: string + deniedBy?: string + reason?: string + detailText?: string +} + +export interface InfraDispatchDenial { + functionId?: string + namespace?: string + message: string +} + +export type InfraErrorDisplay = + | { variant: 'wire'; error: InfraErrorWire } + | { variant: 'invocation'; error: InfraInvocationError } + | { variant: 'dispatch-denied'; error: InfraDispatchDenial } + +/** `{ content: [...], details }` harness result envelope → details. */ +function unwrapEnvelope(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + const obj = value as Record + if (Array.isArray(obj.content) && 'details' in obj) return obj.details + return value +} + +function contentBlocksText(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined + const parts: string[] = [] + for (const block of content) { + if (!block || typeof block !== 'object') continue + const obj = block as Record + if ( + obj.type === 'text' && + typeof obj.text === 'string' && + obj.text.length > 0 + ) { + parts.push(obj.text) + } + } + return parts.length > 0 ? parts.join('\n') : undefined +} + +function extractFirstJsonObject(text: string): unknown | null { + const start = text.indexOf('{') + if (start === -1) return null + let depth = 0 + for (let i = start; i < text.length; i++) { + const ch = text[i] + if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + try { + return JSON.parse(text.slice(start, i + 1)) + } catch { + return null + } + } + } + } + return null +} + +function tryParseWire(value: unknown): InfraErrorWire | null { + const parsed = infraErrorWireSchema.safeParse(unwrapEnvelope(value)) + if (parsed.success) return parsed.data + + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const obj = value as Record + if ( + obj.error === 'handler_error' || + (typeof obj.code === 'string' && /^S\d{3}$/.test(obj.code)) + ) { + const { error: _tag, ...rest } = obj + const tagged = infraErrorWireSchema.safeParse(rest) + if (tagged.success) return tagged.data + } + return null +} + +function tryParseDenial(value: unknown): DenialEnvelopeWire | null { + const parsed = denialEnvelopeSchema.safeParse(unwrapEnvelope(value)) + if (!parsed.success) return null + if (parsed.data.status !== 'denied' && !parsed.data.denied_by) return null + return parsed.data +} + +function denialToInvocation( + denial: DenialEnvelopeWire, + fallbackMessage?: string, + detailText?: string, +): InfraInvocationError { + const deniedBy = denial.denied_by + const title = + deniedBy === 'gate_unavailable' + ? 'Gate unavailable' + : deniedBy === 'permissions' + ? 'Permission denied' + : deniedBy === 'user' + ? 'Denied by user' + : denial.status === 'denied' + ? 'Denied' + : 'Trigger failed' + const message = + denial.reason ?? fallbackMessage ?? 'The browser trigger could not complete.' + return { + title, + message, + functionId: denial.function_id, + deniedBy, + reason: denial.reason, + detailText, + } +} + +function invocationFromFunctionError( + envelope: z.infer, +): InfraInvocationError | null { + const detailText = contentBlocksText(envelope.content) + const denial = + envelope.details != null ? tryParseDenial(envelope.details) : null + if (denial) { + return denialToInvocation(denial, envelope.message, detailText) + } + return { + title: 'Trigger failed', + message: envelope.message, + detailText, + } +} + +function collectErrorCandidates(value: unknown): unknown[] { + const seen = new Set() + const out: unknown[] = [] + const push = (candidate: unknown) => { + if (seen.has(candidate)) return + seen.add(candidate) + out.push(candidate) + } + + push(value) + push(unwrapEnvelope(value)) + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const obj = value as Record + if (obj.error && typeof obj.error === 'object') { + const err = obj.error as Record + push(err) + if ('details' in err) push(err.details) + if (typeof err.message === 'string') push(err.message) + const text = contentBlocksText(err.content) + if (text) push(text) + } + } + + return out +} + +const DISPATCH_DENIAL_RE = /not permitted by this agent.?s dispatch policy/i +const DISPATCH_FN_RE = /function\s+([A-Za-z0-9_:-]+)\s+is not permitted/i + +function candidateText(candidate: unknown): string | undefined { + if (typeof candidate === 'string') return candidate + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + const msg = (candidate as Record).message + if (typeof msg === 'string') return msg + } + return undefined +} + +function parseDispatchDenial(value: unknown): InfraDispatchDenial | null { + for (const candidate of collectErrorCandidates(value)) { + const text = candidateText(candidate) + if (!text || !DISPATCH_DENIAL_RE.test(text)) continue + const functionId = text.match(DISPATCH_FN_RE)?.[1] + const namespace = functionId?.includes('::') + ? functionId.split('::')[0] + : undefined + return { functionId, namespace, message: text } + } + return null +} + +export function parseInfraErrorDisplay( + value: unknown, +): InfraErrorDisplay | null { + const candidates = collectErrorCandidates(value) + + const dispatchDenial = parseDispatchDenial(value) + if (dispatchDenial) { + return { variant: 'dispatch-denied', error: dispatchDenial } + } + + for (const candidate of candidates) { + const wire = tryParseWire(candidate) + if (wire) return { variant: 'wire', error: wire } + + if (typeof candidate === 'string') { + const embedded = extractFirstJsonObject(candidate) + if (embedded != null) { + const wireFromText = tryParseWire(embedded) + if (wireFromText) return { variant: 'wire', error: wireFromText } + } + } + } + + for (const candidate of candidates) { + const denial = tryParseDenial(candidate) + if (denial) { + return { variant: 'invocation', error: denialToInvocation(denial) } + } + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const parsed = functionErrorEnvelopeSchema.safeParse( + (value as Record).error, + ) + if (parsed.success && parsed.data.kind === 'function_error') { + const invocation = invocationFromFunctionError(parsed.data) + if (invocation) { + return { variant: 'invocation', error: invocation } + } + } + } + + return null +} + +function WireErrorView({ error }: { error: InfraErrorWire }) { + return ( +
    +
    + + {error.code} + + {error.type} + {error.retryable === true ? ( + + retryable + + ) : null} +
    +
    +        {error.message}
    +      
    + {error.fix_note ? ( +
    {error.fix_note}
    + ) : null} + {error.docs_url ? ( + + docs ↗ + + ) : null} +
    + ) +} + +function InvocationErrorView({ error }: { error: InfraInvocationError }) { + const badge = error.deniedBy ?? 'error' + const showDetailText = + error.detailText && + error.detailText !== error.message && + error.detailText !== error.reason + return ( +
    +
    + + {badge} + + {error.title} +
    + {error.functionId ? ( +
    + function {error.functionId} +
    + ) : null} +
    +        {error.message}
    +      
    + {showDetailText ? ( +
    +          {error.detailText}
    +        
    + ) : null} +
    + ) +} + +function DispatchDeniedView({ denial }: { denial: InfraDispatchDenial }) { + const fn = denial.functionId + return ( +
    +
    + + denied + + dispatch policy +
    + {fn ? ( +
    + blocked {fn} +
    + ) : null} +
    + This agent's allow-list doesn't cover{' '} + {fn ? {fn} : 'this function'}. Grant + it where the agent is defined (a workflow node's{' '} + agent.functions / the def's{' '} + default_functions, or a session's{' '} + options.functions.allow). +
    +
    +        {denial.message}
    +      
    +
    + ) +} + +export function InfraErrorView({ display }: { display: InfraErrorDisplay }) { + if (display.variant === 'wire') { + return + } + if (display.variant === 'dispatch-denied') { + return + } + return +} diff --git a/browser/ui/src/lib/events.ts b/browser/ui/src/lib/events.ts new file mode 100644 index 000000000..f4d433674 --- /dev/null +++ b/browser/ui/src/lib/events.ts @@ -0,0 +1,191 @@ +import type { Host } from '@iii-dev/console-ui' +import { useEffect, useId, useRef, useState } from 'react' +import { BROWSER_LIFECYCLE_TRIGGERS } from './browser' + +/** + * Browser-local bindings to the browser worker's custom trigger types and + * screencast stream, ported to the injected-UI `host` surface. Each binding + * is `host.iii.on(fnId)` plus `host.iii.registerTrigger` targeting + * `::` (the SDK registers the handler under the same + * namespaced id, so they match). The handler base ids carry the `iii::` + * prefix so per-event invocations stay span-suppressed and out of the trace + * feed; the per-mount `instanceId` keeps two hook instances from colliding. + * + * Every binding is GC'd with the tab and unregistered on unmount, so the + * injected UI's subscriptions die and revive with the page script. + */ + +const LIFECYCLE_FN = 'iii::browser-ui::lifecycle' + +export interface UseBrowserLifecycleEventsOptions { + host: Host + /** Only subscribe while the page is live. */ + enabled: boolean + onEvent: () => void +} + +export interface BrowserLifecycleSubscription { + /** + * True once all three trigger bindings registered. While false (worker + * absent, SDK failure) callers fall back to polling. + */ + bound: boolean +} + +/** + * Page-scoped feed of the session lifecycle trigger types + * (session-started / session-stopped / navigated), for surfaces that re-read + * the session list on any change. + */ +export function useBrowserLifecycleEvents( + opts: UseBrowserLifecycleEventsOptions, +): BrowserLifecycleSubscription { + const { host, enabled } = opts + const onEventRef = useRef(opts.onEvent) + onEventRef.current = opts.onEvent + + const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') + const [bound, setBound] = useState(false) + + useEffect(() => { + if (!enabled) { + setBound(false) + return + } + const offs: Array<() => void> = [] + let registered = 0 + for (const triggerType of BROWSER_LIFECYCLE_TRIGGERS) { + const suffix = triggerType.replace(/[^a-zA-Z0-9]/g, '-') + const localFnId = `${LIFECYCLE_FN}::${suffix}::${instanceId}` + try { + offs.push( + host.iii.on(localFnId, () => { + onEventRef.current() + }), + ) + offs.push( + host.iii.registerTrigger({ + type: triggerType, + function_id: `${localFnId}::${host.iii.browserId}`, + config: {}, + }), + ) + registered += 1 + } catch { + // Worker absent or trigger type unregistered; drop the binding. + } + } + setBound(registered === BROWSER_LIFECYCLE_TRIGGERS.length) + + return () => { + setBound(false) + for (const off of offs) off() + } + }, [host, enabled, instanceId]) + + return { bound } +} + +export interface UseBrowserSessionEventOptions { + host: Host + enabled: boolean + /** Trigger type to bind (e.g. `browser::console-event`). */ + triggerType: string + /** Session the binding filters to (worker-side `session_id` filter). */ + sessionId: string | null + /** Base id for this binding's browser-local handler. */ + fnId: string + onEvent: (payload: unknown) => void +} + +/** + * One session-filtered binding to a browser trigger type (console-event, + * network-event, or picked). Rebinds when the session changes and + * unregisters on unmount. + */ +export function useBrowserSessionEvent( + opts: UseBrowserSessionEventOptions, +): void { + const { host, enabled, triggerType, sessionId, fnId } = opts + const onEventRef = useRef(opts.onEvent) + onEventRef.current = opts.onEvent + + const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') + + useEffect(() => { + if (!enabled || !sessionId) return + const offs: Array<() => void> = [] + const localFnId = `${fnId}::${instanceId}` + try { + offs.push( + host.iii.on(localFnId, (payload: unknown) => { + onEventRef.current(payload) + }), + ) + offs.push( + host.iii.registerTrigger({ + type: triggerType, + function_id: `${localFnId}::${host.iii.browserId}`, + config: { session_id: sessionId }, + }), + ) + } catch { + // Worker absent or trigger type unregistered; drop the binding. + } + + return () => { + for (const off of offs) off() + } + }, [host, enabled, triggerType, sessionId, fnId, instanceId]) +} + +export interface UseBrowserStreamOptions { + host: Host + enabled: boolean + /** iii stream name to subscribe to. */ + streamName: string + /** Stream group (the session id for per-session streams). */ + groupId: string | null + /** Base id for this binding's browser-local handler. */ + fnId: string + onFrame: (payload: unknown) => void +} + +/** + * Subscribe to an iii stream (`type:'stream'`) for a session, the same + * engine-pushes / client-appends pattern the Traces view uses. Rebinds when + * the stream group (session) changes and unregisters on unmount. + */ +export function useBrowserStream(opts: UseBrowserStreamOptions): void { + const { host, enabled, streamName, groupId, fnId } = opts + const onFrameRef = useRef(opts.onFrame) + onFrameRef.current = opts.onFrame + + const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') + + useEffect(() => { + if (!enabled || !groupId) return + const offs: Array<() => void> = [] + const localFnId = `${fnId}::${instanceId}` + try { + offs.push( + host.iii.on(localFnId, (payload: unknown) => { + onFrameRef.current(payload) + }), + ) + offs.push( + host.iii.registerTrigger({ + type: 'stream', + function_id: `${localFnId}::${host.iii.browserId}`, + config: { stream_name: streamName, group_id: groupId }, + }), + ) + } catch { + // Stream not available; the seed read is the fallback. + } + + return () => { + for (const off of offs) off() + } + }, [host, enabled, streamName, groupId, fnId, instanceId]) +} diff --git a/browser/ui/src/lib/format.ts b/browser/ui/src/lib/format.ts new file mode 100644 index 000000000..939b7a51d --- /dev/null +++ b/browser/ui/src/lib/format.ts @@ -0,0 +1,20 @@ +/** + * Pure formatting helpers for the browser UI. Ported from the console's + * sandbox `format.ts` — only the pieces the session rail needs. + */ + +/** Unix seconds → short relative time ("3m ago", "2d ago"). */ +export function formatMtime(unixSecs: number, now = Date.now()): string { + if (!unixSecs || unixSecs <= 0) return '—' + const deltaSecs = Math.max(0, Math.floor(now / 1000 - unixSecs)) + if (deltaSecs < 60) return deltaSecs <= 1 ? 'just now' : `${deltaSecs}s ago` + const mins = Math.floor(deltaSecs / 60) + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.floor(months / 12)}y ago` +} diff --git a/browser/ui/src/lib/highlight.tsx b/browser/ui/src/lib/highlight.tsx new file mode 100644 index 000000000..dcba623c1 --- /dev/null +++ b/browser/ui/src/lib/highlight.tsx @@ -0,0 +1,79 @@ +/** + * Best-effort match highlighting for the snapshot a11y tree ([ref=eN] + * handles). Ported from the console's sandbox `highlight.tsx`; the match + * span carries the scoped `br-ui-hl` class instead of Tailwind utilities. + */ +import type { ReactNode } from 'react' + +export interface HighlightOptions { + /** Treat `query` as a regex; false = literal substring match. */ + isRegex: boolean + ignoreCase: boolean +} + +export function renderWithHighlight( + line: string, + query: string, + { isRegex, ignoreCase }: HighlightOptions, +): ReactNode { + if (!query) return line + if (isRegex) { + let re: RegExp | null = null + try { + re = new RegExp(query, ignoreCase ? 'gi' : 'g') + } catch { + re = null + } + if (re) return highlightRegex(line, re) + } + return highlightSubstring(line, query, ignoreCase) +} + +function highlightRegex(line: string, re: RegExp): ReactNode { + const parts: ReactNode[] = [] + let last = 0 + let n = 0 + for (const hit of line.matchAll(re)) { + const start = hit.index ?? 0 + const text = hit[0] + if (text.length === 0) continue + if (start > last) parts.push(line.slice(last, start)) + parts.push( + + {text} + , + ) + last = start + text.length + n++ + if (n > 200) break + } + if (last < line.length) parts.push(line.slice(last)) + return parts +} + +function highlightSubstring( + line: string, + query: string, + ignoreCase: boolean, +): ReactNode { + const needle = ignoreCase ? query.toLowerCase() : query + const hay = ignoreCase ? line.toLowerCase() : line + const parts: ReactNode[] = [] + let i = 0 + let n = 0 + while (i < line.length) { + const j = hay.indexOf(needle, i) + if (j === -1) { + parts.push(line.slice(i)) + break + } + if (j > i) parts.push(line.slice(i, j)) + parts.push( + + {line.slice(j, j + query.length)} + , + ) + i = j + query.length + } + return parts +} diff --git a/browser/ui/src/lib/icons.tsx b/browser/ui/src/lib/icons.tsx new file mode 100644 index 000000000..b3a3eff66 --- /dev/null +++ b/browser/ui/src/lib/icons.tsx @@ -0,0 +1,126 @@ +/** + * Small inline SVG icon set — the injected page can't pull in lucide-react + * (nothing is bundled but zod), so the handful of glyphs the ported + * components used are hand-drawn here. Every icon inherits `currentColor` and + * takes a numeric `size` (Tailwind `w-*`/`h-*` classes don't apply in + * injected UI). `className` is accepted so components like the console's + * `EmptyState`, which pass one, type-check. + */ + +import type { CSSProperties, ReactNode } from 'react' + +export interface IconProps { + size?: number + className?: string + style?: CSSProperties + 'aria-hidden'?: boolean + 'aria-label'?: string +} + +function Svg({ + size = 14, + children, + className, + style, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...rest +}: IconProps & { children: ReactNode }) { + // Decorative by default. A caller passing aria-label opts into a labeled, + // non-hidden icon (role=img so it reads as a graphic); an explicit + // aria-hidden always wins. + const labeled = ariaLabel != null + return ( + + {children} + + ) +} + +export function Globe(props: IconProps) { + return ( + + + + + + ) +} + +export function Plus(props: IconProps) { + return ( + + + + ) +} + +export function RefreshCw(props: IconProps) { + return ( + + + + + + + ) +} + +export function AlertCircle(props: IconProps) { + return ( + + + + + ) +} + +export function Crosshair(props: IconProps) { + return ( + + + + + ) +} + +export function Square(props: IconProps) { + return ( + + + + ) +} + +export function X(props: IconProps) { + return ( + + + + ) +} + +export function ExternalLink(props: IconProps) { + return ( + + + + + + ) +} diff --git a/browser/ui/src/lib/shared.tsx b/browser/ui/src/lib/shared.tsx new file mode 100644 index 000000000..5e5b6e80e --- /dev/null +++ b/browser/ui/src/lib/shared.tsx @@ -0,0 +1,55 @@ +/** + * Terminal-card primitives shared by the `browser::*` function-trigger views. + * Ported from the console's sandbox `shared.tsx`; Tailwind utility classes are + * replaced with scoped `br-ui-*` classes (see styles.css) and `Badge` comes + * from the console's shared component library. + */ + +import { Badge } from '@iii-dev/console-ui' +import type { ReactNode } from 'react' +import { cn } from './cn' + +export function Chip({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return {children} +} + +export function MetaRow({ children }: { children: ReactNode }) { + return
    {children}
    +} + +export function StatusPill({ + label, + variant = 'default', +}: { + label: string + variant?: 'default' | 'warn' | 'alert' | 'accent' +}) { + return ( + + {label} + + ) +} + +export function ActionLine({ + symbol, + children, + tone = 'accent', +}: { + symbol: string + children: ReactNode + tone?: 'accent' | 'warn' | 'ink' +}) { + return ( +
    + {symbol} +
    {children}
    +
    + ) +} diff --git a/console/web/src/pages/Browser/components/ConsolePanel.tsx b/browser/ui/src/page/ConsolePanel.tsx similarity index 76% rename from console/web/src/pages/Browser/components/ConsolePanel.tsx rename to browser/ui/src/page/ConsolePanel.tsx index 5b3437b9c..22a619ce1 100644 --- a/console/web/src/pages/Browser/components/ConsolePanel.tsx +++ b/browser/ui/src/page/ConsolePanel.tsx @@ -1,14 +1,14 @@ +import { Input, type Host } from '@iii-dev/console-ui' import { useEffect, useRef, useState } from 'react' -import { ConsoleEntryRow } from '@/components/chat/browser/BrowserViews' -import { Input } from '@/components/ui/Input' -import { useBrowserSessionEvent } from '@/hooks/use-browser-events' import { BROWSER_CONSOLE_EVENT_TRIGGER, type BrowserConsoleEntry, errorMessage, parseConsoleEvent, readBrowserConsole, -} from '@/lib/browser' +} from '../lib/browser' +import { useBrowserSessionEvent } from '../lib/events' +import { ConsoleEntryRow } from '../function-trigger-message/BrowserViews' /** * Live console feed for the selected session: seeded from @@ -23,7 +23,7 @@ const SEED_LIMIT = 200 const MAX_ENTRIES = 500 const PATTERN_DEBOUNCE_MS = 300 -const CONSOLE_FEED_FN = 'console::browser-console-feed' +const CONSOLE_FEED_FN = 'iii::browser-ui::console-feed' function matchesPattern(text: string, pattern: string): boolean { if (!pattern) return true @@ -35,11 +35,12 @@ function matchesPattern(text: string, pattern: string): boolean { } interface ConsolePanelProps { + host: Host sessionId: string enabled: boolean } -export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) { +export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { const [pattern, setPattern] = useState('') const [debouncedPattern, setDebouncedPattern] = useState('') const [entries, setEntries] = useState([]) @@ -62,7 +63,7 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) { let cancelled = false lastSeqRef.current = 0 setEntries([]) - void readBrowserConsole(sessionId, { + void readBrowserConsole(host.iii, sessionId, { pattern: debouncedPattern || undefined, limit: SEED_LIMIT, }) @@ -80,9 +81,10 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) { return () => { cancelled = true } - }, [enabled, sessionId, debouncedPattern]) + }, [host, enabled, sessionId, debouncedPattern]) useBrowserSessionEvent({ + host, enabled, triggerType: BROWSER_CONSOLE_EVENT_TRIGGER, sessionId, @@ -98,32 +100,30 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) { }) return ( -
    -
    +
    +
    {dropped > 0 ? ( - + {dropped} older entries dropped from the buffer ) : null}
    {error ? ( -

    {error}

    +

    {error}

    ) : entries.length === 0 ? ( -

    - no console entries yet -

    +

    no console entries yet

    ) : ( // Column-reverse with the newest entry first in the DOM pins the // scroll position to the bottom, terminal-style. -
      +
        {[...entries].reverse().map((entry) => ( ))} diff --git a/console/web/src/pages/Browser/components/NetworkPanel.tsx b/browser/ui/src/page/NetworkPanel.tsx similarity index 59% rename from console/web/src/pages/Browser/components/NetworkPanel.tsx rename to browser/ui/src/page/NetworkPanel.tsx index c328137ac..73a446fe7 100644 --- a/console/web/src/pages/Browser/components/NetworkPanel.tsx +++ b/browser/ui/src/page/NetworkPanel.tsx @@ -1,5 +1,5 @@ +import type { Host } from '@iii-dev/console-ui' import { useEffect, useRef, useState } from 'react' -import { useBrowserSessionEvent } from '@/hooks/use-browser-events' import { BROWSER_NETWORK_EVENT_TRIGGER, type BrowserNetworkEntry, @@ -7,8 +7,9 @@ import { formatTime, parseNetworkEvent, readBrowserNetwork, -} from '@/lib/browser' -import { cn } from '@/lib/utils' +} from '../lib/browser' +import { cn } from '../lib/cn' +import { useBrowserSessionEvent } from '../lib/events' /** * Network feed for the selected session: seeded from `browser::network::read`, @@ -20,14 +21,15 @@ import { cn } from '@/lib/utils' const SEED_LIMIT = 200 const MAX_ENTRIES = 500 -const NETWORK_FEED_FN = 'console::browser-network-feed' +const NETWORK_FEED_FN = 'iii::browser-ui::network-feed' interface NetworkPanelProps { + host: Host sessionId: string enabled: boolean } -export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) { +export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { const [failedOnly, setFailedOnly] = useState(false) const [entries, setEntries] = useState([]) const [dropped, setDropped] = useState(0) @@ -41,7 +43,7 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) { let cancelled = false lastSeqRef.current = 0 setEntries([]) - void readBrowserNetwork(sessionId, { failedOnly, limit: SEED_LIMIT }) + void readBrowserNetwork(host.iii, sessionId, { failedOnly, limit: SEED_LIMIT }) .then((res) => { if (cancelled || !res) return setEntries(res.entries) @@ -56,9 +58,10 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) { return () => { cancelled = true } - }, [enabled, sessionId, failedOnly]) + }, [host, enabled, sessionId, failedOnly]) useBrowserSessionEvent({ + host, enabled, triggerType: BROWSER_NETWORK_EVENT_TRIGGER, sessionId, @@ -74,67 +77,49 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) { }) return ( -
        -
        +
        +
        {dropped > 0 ? ( - + {dropped} older requests dropped from the buffer ) : null}
        {error ? ( -

        {error}

        +

        {error}

        ) : entries.length === 0 ? ( -

        +

        {failedOnly ? 'no failed requests' : 'no requests yet'}

        ) : ( -
          +
            {[...entries].reverse().map((entry) => ( -
          • - +
          • + {formatTime(entry.timestamp)} {entry.status ?? (entry.failed ? 'err' : '...')} - - {entry.method} - - + {entry.method} + {entry.url} {entry.error ? ( - · {entry.error} + · {entry.error} ) : null} {entry.mime_type ? ( - - {entry.mime_type} - + {entry.mime_type} ) : null}
          • ))} diff --git a/browser/ui/src/page/SessionRail.tsx b/browser/ui/src/page/SessionRail.tsx new file mode 100644 index 000000000..c688f2d59 --- /dev/null +++ b/browser/ui/src/page/SessionRail.tsx @@ -0,0 +1,70 @@ +import type { BrowserSessionInfo } from '../lib/browser' +import { cn } from '../lib/cn' +import { formatMtime } from '../lib/format' +import { Globe } from '../lib/icons' + +/** + * Left rail: one row per live Chromium session. Selection is page-local + * state (the injected page owns its own routing), so a click just flips the + * selected id. + */ + +interface SessionRailProps { + sessions: BrowserSessionInfo[] + selectedId: string | null + onSelect: (sessionId: string) => void +} + +function hostOf(url: string): string { + try { + const parsed = new URL(url) + return parsed.host || url + } catch { + return url + } +} + +export function SessionRail({ + sessions, + selectedId, + onSelect, +}: SessionRailProps) { + return ( + + ) +} diff --git a/console/web/src/pages/Browser/components/SessionView.tsx b/browser/ui/src/page/SessionView.tsx similarity index 64% rename from console/web/src/pages/Browser/components/SessionView.tsx rename to browser/ui/src/page/SessionView.tsx index ebdeb4cea..ddb34137f 100644 --- a/console/web/src/pages/Browser/components/SessionView.tsx +++ b/browser/ui/src/page/SessionView.tsx @@ -1,9 +1,5 @@ -import { Crosshair, Square, X } from 'lucide-react' +import { Button, type Host, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@iii-dev/console-ui' import { useCallback, useEffect, useRef, useState } from 'react' -import { Button } from '@/components/ui/Button' -import { Input } from '@/components/ui/Input' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' -import { useBrowserSessionEvent } from '@/hooks/use-browser-events' import { BROWSER_PICKED_TRIGGER, type BrowserClickOptions, @@ -23,24 +19,29 @@ import { stopBrowserPick, stopBrowserSession, typeBrowserText, -} from '@/lib/browser' -import { insertIntoComposer } from '@/lib/composer-insert' -import { cn } from '@/lib/utils' -import { useLiveFrames } from '../hooks/useLiveFrames' +} from '../lib/browser' +import { cn } from '../lib/cn' +import { useBrowserSessionEvent } from '../lib/events' +import { Crosshair, Square, X } from '../lib/icons' import { ConsolePanel } from './ConsolePanel' import { NetworkPanel } from './NetworkPanel' +import { useLiveFrames } from './useLiveFrames' import { Viewport } from './Viewport' /** - * Everything for one selected session: the URL bar, pick-to-chat toggle, - * stop control, the screencast-fed viewport, and the console / network - * tabs. + * Everything for one selected session: the URL bar, pick-to-clipboard toggle, + * stop control, the screencast-fed viewport, and the console / network tabs. + * + * Pick-to-chat became pick-to-clipboard: the injected UI has no composer slot + * (host.composer is unimplemented), so a picked element's summary is copied + * to the clipboard for the user to paste into chat. */ -const PICKED_FN = 'console::browser-picked' +const PICKED_FN = 'iii::browser-ui::picked' const TYPE_FLUSH_MS = 200 interface SessionViewProps { + host: Host session: BrowserSessionInfo enabled: boolean onSessionsRefresh: () => void @@ -48,6 +49,7 @@ interface SessionViewProps { } export function SessionView({ + host, session, enabled, onSessionsRefresh, @@ -55,7 +57,7 @@ export function SessionView({ }: SessionViewProps) { const sessionId = session.session_id const [picking, setPicking] = useState(false) - const live = useLiveFrames(sessionId, enabled) + const live = useLiveFrames(host, sessionId, enabled) const [actionError, setActionError] = useState(null) const runAction = useCallback(async (action: () => Promise) => { @@ -90,13 +92,13 @@ export function SessionView({ // `host:port` like localhost:3000 is not a scheme and must get https://. if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) url = `https://${url}` void runAction(async () => { - await navigateBrowser(sessionId, url) + await navigateBrowser(host.iii, sessionId, url) onSessionsRefresh() }) - }, [urlDraft, sessionId, runAction, onSessionsRefresh]) + }, [host, urlDraft, sessionId, runAction, onSessionsRefresh]) - // Pick-to-chat. The worker auto-exits inspect mode after one pick, so a - // received event only flips local state; explicit toggles and unmounts + // Pick-to-clipboard. The worker auto-exits inspect mode after one pick, so + // a received event only flips local state; explicit toggles and unmounts // send pick::stop. const [lastPicked, setLastPicked] = useState(null) const pickingRef = useRef(false) @@ -108,22 +110,22 @@ export function SessionView({ setActionError(null) return () => { if (pickingRef.current) { - void stopBrowserPick(sessionId).catch(() => {}) + void stopBrowserPick(host.iii, sessionId).catch(() => {}) } } - }, [sessionId]) + }, [host, sessionId]) const togglePick = useCallback(() => { if (picking) { setPicking(false) - void runAction(() => stopBrowserPick(sessionId)) + void runAction(() => stopBrowserPick(host.iii, sessionId)) return } void runAction(async () => { - await startBrowserPick(sessionId) + await startBrowserPick(host.iii, sessionId) setPicking(true) }) - }, [picking, sessionId, runAction]) + }, [host, picking, sessionId, runAction]) useEffect(() => { if (!picking) return @@ -131,13 +133,14 @@ export function SessionView({ if (e.key !== 'Escape') return e.preventDefault() setPicking(false) - void stopBrowserPick(sessionId).catch(() => {}) + void stopBrowserPick(host.iii, sessionId).catch(() => {}) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [picking, sessionId]) + }, [host, picking, sessionId]) useBrowserSessionEvent({ + host, enabled: enabled && picking, triggerType: BROWSER_PICKED_TRIGGER, sessionId, @@ -146,16 +149,20 @@ export function SessionView({ const evt = parsePickedEvent(payload) if (!evt || evt.session_id !== sessionId) return setLastPicked(evt) - insertIntoComposer(formatPickedElement(evt)) + // No composer slot in injected UI: copy the summary for the user to + // paste into chat. + void navigator.clipboard + ?.writeText(formatPickedElement(evt)) + .catch(() => {}) setPicking(false) }, }) const handleClickAt = useCallback( (x: number, y: number, options?: BrowserClickOptions) => { - void runAction(() => clickBrowserAt(sessionId, x, y, options)) + void runAction(() => clickBrowserAt(host.iii, sessionId, x, y, options)) }, - [sessionId, runAction], + [host, sessionId, runAction], ) // Pick-mode click: resolve the element at the exact clicked point (the @@ -163,16 +170,16 @@ export function SessionView({ // what gets picked. const handlePickAt = useCallback( (x: number, y: number) => { - void runAction(() => resolveBrowserPick(sessionId, x, y)) + void runAction(() => resolveBrowserPick(host.iii, sessionId, x, y)) }, - [sessionId, runAction], + [host, sessionId, runAction], ) const handleScrollAt = useCallback( (x: number, y: number, deltaY: number) => { - void runAction(() => scrollBrowserAt(sessionId, x, y, deltaY)) + void runAction(() => scrollBrowserAt(host.iii, sessionId, x, y, deltaY)) }, - [sessionId, runAction], + [host, sessionId, runAction], ) // Printable characters batch into one type act per idle window; a special @@ -190,8 +197,8 @@ export function SessionView({ const flushTypeBuffer = useCallback(() => { const text = takeTypeBuffer() if (!text) return - void runAction(() => typeBrowserText(sessionId, text)) - }, [takeTypeBuffer, sessionId, runAction]) + void runAction(() => typeBrowserText(host.iii, sessionId, text)) + }, [host, takeTypeBuffer, sessionId, runAction]) const handleTextInput = useCallback( (text: string) => { @@ -206,17 +213,16 @@ export function SessionView({ (key: string) => { const text = takeTypeBuffer() void runAction(async () => { - if (text) await typeBrowserText(sessionId, text) - await pressBrowserKey(sessionId, key) + if (text) await typeBrowserText(host.iii, sessionId, text) + await pressBrowserKey(host.iii, sessionId, key) }) }, - [takeTypeBuffer, sessionId, runAction], + [host, takeTypeBuffer, sessionId, runAction], ) // Flush any buffered text before the session changes or the component // unmounts, so keystrokes typed against one session are sent to that - // session rather than dropped (or leaking into the next one). The cleanup - // captures the flush bound to the session that received the text. + // session rather than dropped (or leaking into the next one). useEffect(() => { return () => { flushTypeBuffer() @@ -224,24 +230,21 @@ export function SessionView({ }, [flushTypeBuffer]) const requestHint = useCallback( - (x: number, y: number) => hintBrowserPick(sessionId, x, y), - [sessionId], + (x: number, y: number) => hintBrowserPick(host.iii, sessionId, x, y), + [host, sessionId], ) const handleStop = useCallback(() => { void runAction(async () => { - await stopBrowserSession(sessionId) + await stopBrowserSession(host.iii, sessionId) onSessionsRefresh() onStopped() }) - }, [sessionId, runAction, onSessionsRefresh, onStopped]) + }, [host, sessionId, runAction, onSessionsRefresh, onStopped]) return ( -
            -
            +
            +
            { if (e.key === 'Enter') submitUrl() }} - className="h-8 text-[12px] flex-1 min-w-0" + className="br-ui-url-input" /> -
            {lastPicked ? ( -
            - - picked - +
            + picked - - {lastPicked.element.ref} - - + {lastPicked.element.ref} + {pickedSelector(lastPicked.element)} - - sent to the chat composer - + copied to clipboard
            ) : null} {actionError ? ( -

            - {actionError} -

            +

            {actionError}

            ) : null} - - + + console network - - + + - - + +
            diff --git a/console/web/src/pages/Browser/components/Viewport.tsx b/browser/ui/src/page/Viewport.tsx similarity index 85% rename from console/web/src/pages/Browser/components/Viewport.tsx rename to browser/ui/src/page/Viewport.tsx index 10366ce3d..03a7a4a1d 100644 --- a/console/web/src/pages/Browser/components/Viewport.tsx +++ b/browser/ui/src/page/Viewport.tsx @@ -3,22 +3,21 @@ import { type BrowserClickOptions, type BrowserPickHint, elementLabel, -} from '@/lib/browser' -import { cn } from '@/lib/utils' -import type { LiveFrame } from '../hooks/useLiveFrames' +} from '../lib/browser' +import { cn } from '../lib/cn' +import type { LiveFrame } from './useLiveFrames' /** * The session viewport: the latest screencast frame scaled to fit the pane * (aspect preserved, centered, letterboxed), acting as a real browser * surface. Mouse and keyboard map from the rendered image rect to - * page-viewport space (the frame's width/height against - * `getBoundingClientRect` at event time), so the mapping survives any pane - * size: clicks, double clicks, right clicks, wheel scroll, and (while the - * surface is focused) typing and special keys all forward as - * `browser::act`. Events in the letterbox margin outside the image do - * nothing. In pick mode the page is in inspect mode: the forwarded click - * resolves the pick, and a throttled `browser::pick::hint` drives the - * client-drawn hover highlight over the image. + * page-viewport space, so the mapping survives any pane size: clicks, double + * clicks, right clicks, wheel scroll, and (while the surface is focused) + * typing and special keys all forward as `browser::act`. Events in the + * letterbox margin outside the image do nothing. In pick mode the page is in + * inspect mode: the forwarded click resolves the pick, and a throttled + * `browser::pick::hint` drives the client-drawn hover highlight over the + * image. */ const HINT_INTERVAL_MS = 120 @@ -306,12 +305,7 @@ export function Viewport({ onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} onKeyDown={handleKeyDown} - className={cn( - 'relative flex-1 min-h-0 min-w-0 overflow-hidden bg-panel p-3', - 'flex items-center justify-center', - picking ? 'cursor-crosshair' : 'cursor-default', - 'outline-none focus-visible:ring-1 focus-visible:ring-ring', - )} + className={cn('br-ui-vp', picking && 'is-picking')} > {frame ? ( live view of the current page ) : ( -

            +

            {error ? `live view failed: ${error}` : loading @@ -336,7 +327,7 @@ export function Viewport({ {hint ? (

            = 22 ? 'bottom-full mb-0.5' : 'top-full mt-0.5', + 'br-ui-vp-hint-label', + hint.top >= 22 ? 'above' : 'below', )} > - {hint.label} - {hint.dims} + {hint.label} + {hint.dims}
            ) : null} diff --git a/browser/ui/src/page/index.tsx b/browser/ui/src/page/index.tsx new file mode 100644 index 000000000..2e35580c7 --- /dev/null +++ b/browser/ui/src/page/index.tsx @@ -0,0 +1,158 @@ +import { + Button, + EmptyState, + type Host, + StatusDot, + StatusPanel, +} from '@iii-dev/console-ui' +import { useEffect, useMemo, useState } from 'react' +import { errorMessage, startBrowserSession } from '../lib/browser' +import { cn } from '../lib/cn' +import { AlertCircle, Globe, Plus, RefreshCw } from '../lib/icons' +import { SessionRail } from './SessionRail' +import { SessionView } from './SessionView' +import { useBrowserSessionsLive } from './useBrowserSessionsLive' + +/** + * The browser page (#/ext/browser): the browser worker's live surface — a + * session rail, a screencast-fed viewport, and console/network feeds for the + * selected session, so a user can watch what an agent is doing in a Chromium + * session and pick elements into the clipboard. + * + * The host only mounts this page while the browser worker is connected, so + * there is no presence gate here (worker disconnect disposes the script and + * drops the nav entry). Session selection is page-local state — the injected + * page owns its own view, and the host owns the `#/ext/browser` route. + */ + +export function BrowserPage({ host }: { host: Host }) { + const { sessions, loading, error, live, refresh } = useBrowserSessionsLive( + host, + true, + ) + + const [selectedId, setSelectedId] = useState(null) + const selected = useMemo( + () => sessions.find((s) => s.session_id === selectedId) ?? null, + [sessions, selectedId], + ) + + // Auto-select the first session when nothing (or a stopped session) is + // selected, and clear a selection whose session is gone. + useEffect(() => { + if (loading) return + if (selectedId && sessions.some((s) => s.session_id === selectedId)) return + const next = sessions[0]?.session_id ?? null + if (next !== selectedId) setSelectedId(next) + }, [loading, sessions, selectedId]) + + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(null) + const handleNewSession = async () => { + if (starting) return + setStarting(true) + try { + const started = await startBrowserSession(host.iii) + setStartError(null) + refresh() + if (started) setSelectedId(started.session_id) + } catch (err) { + setStartError(errorMessage(err)) + } finally { + setStarting(false) + } + } + + const countLabel = loading ? '...' : String(sessions.length) + + return ( +
            +
            +
            +

            browser

            +

            {countLabel} sessions

            +
            +
            + + + {live ? 'live' : 'polling'} + + + +
            +
            + + {error ? ( +
            + } + headline="failed to load browser sessions" + detail={error} + /> +
            + ) : !loading && sessions.length === 0 ? ( +
            + {startError ? ( + } + headline="could not start a session" + detail={startError} + /> + ) : null} + +
            + ) : ( +
            + + {selected ? ( + setSelectedId(null)} + /> + ) : ( +
            +

            select a session

            +
            + )} +
            + )} +
            + ) +} diff --git a/console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts b/browser/ui/src/page/useBrowserSessionsLive.ts similarity index 71% rename from console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts rename to browser/ui/src/page/useBrowserSessionsLive.ts index 0e81972b6..37cf76ec9 100644 --- a/console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts +++ b/browser/ui/src/page/useBrowserSessionsLive.ts @@ -1,17 +1,18 @@ +import type { Host } from '@iii-dev/console-ui' import { useCallback, useEffect, useState } from 'react' -import { useBrowserLifecycleEvents } from '@/hooks/use-browser-events' import { type BrowserSessionInfo, errorMessage, listBrowserSessions, -} from '@/lib/browser' +} from '../lib/browser' +import { useBrowserLifecycleEvents } from '../lib/events' /** - * Live session feed for the Browser page: `browser::sessions::list`, - * re-read on session-started / session-stopped / navigated. While the event - * bindings are unavailable (SDK hiccup, races around worker restart) a - * modest poll keeps the rail honest, skipped entirely while the tab is - * hidden so a backgrounded console never hammers the engine. + * Live session feed for the browser page: `browser::sessions::list`, re-read + * on session-started / session-stopped / navigated. While the event bindings + * are unavailable (SDK hiccup, races around worker restart) a modest poll + * keeps the rail honest, skipped entirely while the tab is hidden so a + * backgrounded console never hammers the engine. */ export const BROWSER_SESSIONS_POLL_MS = 10_000 @@ -25,7 +26,10 @@ export interface BrowserSessionsLive { refresh: () => void } -export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive { +export function useBrowserSessionsLive( + host: Host, + enabled: boolean, +): BrowserSessionsLive { const [sessions, setSessions] = useState([]) const [loading, setLoading] = useState(enabled) const [error, setError] = useState(null) @@ -34,6 +38,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive { const refresh = useCallback(() => setToken((t) => t + 1), []) const { bound } = useBrowserLifecycleEvents({ + host, enabled, onEvent: refresh, }) @@ -48,7 +53,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive { let cancelled = false void (async () => { try { - const next = await listBrowserSessions() + const next = await listBrowserSessions(host.iii) if (cancelled) return setSessions(next) setError(null) @@ -62,7 +67,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive { return () => { cancelled = true } - }, [enabled, token]) + }, [host, enabled, token]) useEffect(() => { if (!enabled || bound) return diff --git a/console/web/src/pages/Browser/hooks/useLiveFrames.ts b/browser/ui/src/page/useLiveFrames.ts similarity index 87% rename from console/web/src/pages/Browser/hooks/useLiveFrames.ts rename to browser/ui/src/page/useLiveFrames.ts index 32972d929..4ca628297 100644 --- a/console/web/src/pages/Browser/hooks/useLiveFrames.ts +++ b/browser/ui/src/page/useLiveFrames.ts @@ -1,5 +1,5 @@ +import type { Host } from '@iii-dev/console-ui' import { useEffect, useRef, useState } from 'react' -import { useBrowserStream } from '@/hooks/use-browser-events' import { BROWSER_FRAMES_STREAM, extractStreamFrame, @@ -7,7 +7,8 @@ import { startBrowserScreencast, stopBrowserScreencast, takeBrowserScreenshot, -} from '@/lib/browser' +} from '../lib/browser' +import { useBrowserStream } from '../lib/events' /** * Live view for the selected session, fed by the worker's screencast stream: @@ -36,6 +37,7 @@ export interface LiveViewState { } export function useLiveFrames( + host: Host, sessionId: string | null, enabled: boolean, ): LiveViewState { @@ -60,7 +62,7 @@ export function useLiveFrames( // Retain the start so teardown can wait for it to settle before stopping; // otherwise a late start could reactivate the screencast after cleanup. - const started = startBrowserScreencast(sessionId) + const started = startBrowserScreencast(host.iii, sessionId) void (async () => { try { @@ -68,7 +70,9 @@ export function useLiveFrames( } catch { // Older worker without the screencast surface: one screenshot so the // viewport is not blank. - const shot = await takeBrowserScreenshot(sessionId).catch(() => null) + const shot = await takeBrowserScreenshot(host.iii, sessionId).catch( + () => null, + ) if (!cancelled && shot?.dataUrl) { setFrame({ dataUrl: shot.dataUrl, @@ -81,7 +85,7 @@ export function useLiveFrames( if (cancelled) return // Immediate first paint: the stream only delivers frames produced after // the subscription, so read the current one once. - const seed = await readBrowserFrame(sessionId).catch(() => null) + const seed = await readBrowserFrame(host.iii, sessionId).catch(() => null) if (cancelled || !seed?.frame) return if (seed.frame_seq > lastSeqRef.current) { lastSeqRef.current = seed.frame_seq @@ -100,16 +104,17 @@ export function useLiveFrames( // overtaken by an in-flight start reactivating the screencast. void started .catch(() => {}) - .then(() => stopBrowserScreencast(sessionId)) + .then(() => stopBrowserScreencast(host.iii, sessionId)) .catch(() => {}) } - }, [enabled, sessionId]) + }, [host, enabled, sessionId]) useBrowserStream({ + host, enabled: enabled && !!sessionId, streamName: BROWSER_FRAMES_STREAM, groupId: sessionId, - fnId: 'console::browser-frames', + fnId: 'iii::browser-ui::frames', onFrame: (payload) => { const f = extractStreamFrame(payload) if (!f || f.frame_seq <= lastSeqRef.current) return diff --git a/browser/ui/styles.css b/browser/ui/styles.css new file mode 100644 index 000000000..d5e955471 --- /dev/null +++ b/browser/ui/styles.css @@ -0,0 +1,863 @@ +/* + * Styles for the browser worker's injected console UI. Shipped as its own + * `console:style` asset (browser/styles.css) — the console mounts it as a + * and link-swaps it on change. + * + * EVERY rule is scoped under [data-iii-ui="browser"] — the wrapper the console + * mounts around every injected render (page, function-trigger card alike). + * Unscoped rules would beat the console's layered CSS document-wide (injected + * sheets are unlayered). Keyframe names are global: keep the br-ui- prefix. + * Colors come from the console's design tokens so light/dark theming is free. + */ + +[data-iii-ui="browser"] .br-ui-page, +[data-iii-ui="browser"] .br-ui-call, +[data-iii-ui="browser"] .br-ui-err { + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); + box-sizing: border-box; +} +[data-iii-ui="browser"] .br-ui-page *, +[data-iii-ui="browser"] .br-ui-page *::before, +[data-iii-ui="browser"] .br-ui-page *::after { + box-sizing: border-box; +} + +@keyframes br-ui-spin { + to { + transform: rotate(360deg); + } +} +@keyframes br-ui-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} +[data-iii-ui="browser"] .br-ui-spin { + animation: br-ui-spin 1s linear infinite; +} + +/* --- page shell ------------------------------------------------------ */ +[data-iii-ui="browser"] .br-ui-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} +[data-iii-ui="browser"] .br-ui-page-head { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 24px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="browser"] .br-ui-page-title { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + text-transform: lowercase; +} +[data-iii-ui="browser"] .br-ui-page-sub { + margin: 2px 0 0; + font-size: 12px; + color: var(--color-ink-faint); + text-transform: lowercase; +} +[data-iii-ui="browser"] .br-ui-page-actions { + display: flex; + align-items: center; + gap: 12px; +} +[data-iii-ui="browser"] .br-ui-live { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-page-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* --- session split --------------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-split { + flex: 1; + display: flex; + min-height: 0; +} +[data-iii-ui="browser"] .br-ui-aside { + width: 288px; + flex-shrink: 0; + border-right: 1px solid var(--color-rule); + overflow-y: auto; +} +[data-iii-ui="browser"] .br-ui-aside-err { + margin: 0; + padding: 8px 12px; + border-bottom: 1px solid var(--color-rule-2); + font-size: 11px; + color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-page-placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} + +/* --- session rail ---------------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-rail { + display: flex; + flex-direction: column; +} +[data-iii-ui="browser"] .br-ui-rail-row { + width: 100%; + text-align: left; + border: none; + border-bottom: 1px solid var(--color-rule-2); + background: transparent; + padding: 10px 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.12s; +} +[data-iii-ui="browser"] .br-ui-rail-row:hover { + background: var(--color-paper-2); +} +[data-iii-ui="browser"] .br-ui-rail-row.is-selected { + background: var(--color-panel); +} +[data-iii-ui="browser"] .br-ui-rail-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +[data-iii-ui="browser"] .br-ui-rail-icon { + flex-shrink: 0; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-rail-icon.is-selected { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-rail-title { + font-size: 12px; + color: var(--color-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-rail-url { + display: flex; + margin-top: 2px; + min-width: 0; + font-size: 11px; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-rail-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; + font-size: 10px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +[data-iii-ui="browser"] .br-ui-num { + font-variant-numeric: tabular-nums; +} + +/* --- session view ---------------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-session { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +[data-iii-ui="browser"] .br-ui-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="browser"] .br-ui-url-input { + flex: 1; + min-width: 0; +} +[data-iii-ui="browser"] .br-ui-pick-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 10px; + font-family: inherit; + font-size: 12px; + text-transform: lowercase; + border: 1px solid var(--color-rule); + background: transparent; + color: var(--color-ink-faint); + cursor: pointer; + transition: + color 0.12s, + border-color 0.12s, + background 0.12s; +} +[data-iii-ui="browser"] .br-ui-pick-btn:hover { + color: var(--color-ink); + border-color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-pick-btn.is-on { + background: var(--color-accent); + color: var(--color-accent-fg); + border-color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-picked { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); + background: var(--color-paper-2); +} +[data-iii-ui="browser"] .br-ui-picked-label { + flex-shrink: 0; + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-picked-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--color-rule); + background: var(--color-bg); + padding: 2px 8px; + font-size: 12px; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-picked-ref { + flex-shrink: 0; + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-picked-x { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--color-ink-faint); + cursor: pointer; + display: inline-flex; + transition: color 0.12s; +} +[data-iii-ui="browser"] .br-ui-picked-x:hover { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-picked-note { + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-ghost); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-session-err { + flex-shrink: 0; + margin: 0; + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); + font-size: 12px; + color: var(--color-alert); +} + +/* --- viewport -------------------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-vp { + position: relative; + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + background: var(--color-panel); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: default; + outline: none; +} +[data-iii-ui="browser"] .br-ui-vp.is-picking { + cursor: crosshair; +} +[data-iii-ui="browser"] .br-ui-vp:focus-visible { + outline: 1px solid var(--color-ring); + outline-offset: -1px; +} +[data-iii-ui="browser"] .br-ui-vp-img { + display: block; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + user-select: none; + border: 1px solid var(--color-rule); + background: var(--color-bg); +} +[data-iii-ui="browser"] .br-ui-vp-img.is-picking { + border-color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-vp-empty { + margin: 0; + font-size: 12px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-vp-hint { + position: absolute; + pointer-events: none; + border: 1px solid var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 15%, transparent); +} +[data-iii-ui="browser"] .br-ui-vp-hint-label { + position: absolute; + left: 0; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + background: var(--color-ink); + color: var(--color-bg); + padding: 2px 6px; + font-size: 10px; + line-height: 1; +} +[data-iii-ui="browser"] .br-ui-vp-hint-label.above { + bottom: 100%; + margin-bottom: 2px; +} +[data-iii-ui="browser"] .br-ui-vp-hint-label.below { + top: 100%; + margin-top: 2px; +} +[data-iii-ui="browser"] .br-ui-vp-hint-tag { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-vp-hint-dims { + opacity: 0.7; + font-variant-numeric: tabular-nums; +} + +/* --- tabs + panels --------------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-tabs { + flex-shrink: 0; + height: 38%; + min-height: 176px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-rule); +} +[data-iii-ui="browser"] .br-ui-tabs-list { + flex-shrink: 0; + padding-left: 12px; + padding-right: 12px; +} +[data-iii-ui="browser"] .br-ui-tabs-content { + flex: 1; + min-height: 0; +} +[data-iii-ui="browser"] .br-ui-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +[data-iii-ui="browser"] .br-ui-panel-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-filter-input { + max-width: 280px; +} +[data-iii-ui="browser"] .br-ui-panel-note { + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-panel-err { + margin: 0; + padding: 8px 12px; + font-size: 12px; + color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-panel-empty { + margin: 0; + padding: 8px 12px; + font-size: 12px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-feed { + flex: 1; + min-height: 0; + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; + display: flex; + flex-direction: column-reverse; +} +[data-iii-ui="browser"] .br-ui-toggle { + height: 28px; + padding: 0 10px; + font-family: inherit; + font-size: 11px; + text-transform: lowercase; + border: 1px solid var(--color-rule); + background: transparent; + color: var(--color-ink-faint); + cursor: pointer; + transition: + color 0.12s, + border-color 0.12s, + background 0.12s; +} +[data-iii-ui="browser"] .br-ui-toggle:hover { + color: var(--color-ink); + border-color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-toggle.is-on { + background: var(--color-ink); + color: var(--color-bg); + border-color: var(--color-ink); +} + +/* network rows (live panel) */ +[data-iii-ui="browser"] .br-ui-nrow { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 4px 12px; + border-top: 1px solid var(--color-rule-2); + font-size: 12px; + line-height: 1.55; +} +[data-iii-ui="browser"] .br-ui-nrow-time { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-nrow-status { + flex-shrink: 0; + width: 42px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-nrow-status.is-failed { + color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-nrow-method { + flex-shrink: 0; + width: 56px; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-nrow-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-nrow-mime { + flex-shrink: 0; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-alert { + color: var(--color-alert); +} + +/* --- function-trigger card ------------------------------------------- */ +[data-iii-ui="browser"] .br-ui-call { + display: flex; + flex-direction: column; + background: var(--color-bg); +} +[data-iii-ui="browser"] .br-ui-call-head { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--color-paper-2); + border-bottom: 1px solid var(--color-rule-2); + font-size: 11px; + text-transform: lowercase; +} +[data-iii-ui="browser"] .br-ui-call-session { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-call-sid { + color: var(--color-ink); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-call-link { + margin-left: auto; + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-ink-faint); + text-decoration: none; + transition: color 0.12s; +} +[data-iii-ui="browser"] .br-ui-call-link:hover { + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-call-running { + margin: 0; + padding: 8px 12px; + font-size: 12px; + text-transform: lowercase; + color: var(--color-ink-faint); + animation: br-ui-pulse 1.6s ease-in-out infinite; +} + +/* --- shared card primitives ------------------------------------------ */ +[data-iii-ui="browser"] .br-ui-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); + background: var(--color-paper-2); +} +[data-iii-ui="browser"] .br-ui-pill { + text-transform: none; + letter-spacing: normal; +} +[data-iii-ui="browser"] .br-ui-chip { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-rule-2); + background: var(--color-paper-2); + border-radius: 2px; + padding: 2px 6px; + font-size: 10px; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-chip-accent { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-chip-num { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-chip-warn { + color: var(--color-warn); + border-color: color-mix(in srgb, var(--color-warn) 40%, transparent); +} +[data-iii-ui="browser"] .br-ui-action-line { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-rule-2); + background: var(--color-bg); +} +[data-iii-ui="browser"] .br-ui-action-sym { + flex-shrink: 0; + font-weight: 600; +} +[data-iii-ui="browser"] .br-ui-action-sym.tone-accent { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-action-sym.tone-warn { + color: var(--color-warn); +} +[data-iii-ui="browser"] .br-ui-action-sym.tone-ink { + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-action-body { + min-width: 0; + color: var(--color-ink); + word-break: break-all; +} +[data-iii-ui="browser"] .br-ui-break { + word-break: break-all; +} +[data-iii-ui="browser"] .br-ui-hl { + background: color-mix(in srgb, var(--color-accent) 15%, transparent); + color: var(--color-accent); +} + +/* snapshot / dom trees */ +[data-iii-ui="browser"] .br-ui-tree { + margin: 0; + max-height: 384px; + overflow: auto; + padding: 8px 12px; + font-size: 12px; + line-height: 1.55; + color: var(--color-ink); + white-space: pre-wrap; + word-break: break-word; +} +[data-iii-ui="browser"] .br-ui-dom { + max-height: 384px; + overflow: auto; + padding: 8px 12px; + font-size: 12px; + line-height: 1.55; +} +[data-iii-ui="browser"] .br-ui-dom-row { + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-dom-text { + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-dom-el { + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-dom-ref { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-dom-more { + color: var(--color-ink-ghost); +} + +/* tables + lists in cards */ +[data-iii-ui="browser"] .br-ui-empty-line { + padding: 12px; + font-size: 12.5px; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-vtable { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-vtable tr { + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-vtable tr:last-child { + border-bottom: none; +} +[data-iii-ui="browser"] .br-ui-td { + padding: 4px 12px; +} +[data-iii-ui="browser"] .br-ui-td-accent { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-td-dim { + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-td-name { + width: 45%; +} +[data-iii-ui="browser"] .br-ui-nowrap { + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-right { + text-align: right; +} +[data-iii-ui="browser"] .br-ui-scroll { + max-height: 320px; + overflow: auto; + margin: 0; + padding: 0; + list-style: none; +} +[data-iii-ui="browser"] .br-ui-json { + max-height: 256px; + overflow: auto; +} +[data-iii-ui="browser"] .br-ui-json-sm { + max-height: 320px; + overflow: auto; +} +[data-iii-ui="browser"] .br-ui-inline-style { + padding: 6px 12px; + border-top: 1px solid var(--color-rule-2); + font-size: 11.5px; + color: var(--color-ink-faint); + word-break: break-all; +} +[data-iii-ui="browser"] .br-ui-eval-err { + padding: 8px 12px; + font-size: 12px; + color: var(--color-alert); + white-space: pre-wrap; + word-break: break-word; +} + +/* console log rows (shared by card + live panel) */ +[data-iii-ui="browser"] .br-ui-log-row { + display: flex; + align-items: flex-start; + gap: 8px; + border-bottom: 1px solid var(--color-rule-2); + padding: 4px 12px; + font-size: 12px; + line-height: 1.55; +} +[data-iii-ui="browser"] .br-ui-log-row:last-child { + border-bottom: none; +} +[data-iii-ui="browser"] .br-ui-log-time { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-log-level { + width: 72px; + flex-shrink: 0; +} +[data-iii-ui="browser"] .br-ui-log-text { + min-width: 0; + flex: 1; + white-space: pre-wrap; + word-break: break-word; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-dim { + color: var(--color-ink-ghost); +} + +/* network rows (card) */ +[data-iii-ui="browser"] .br-ui-net-status { + width: 42px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-net-status.is-failed { + color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-net-method { + width: 56px; + flex-shrink: 0; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-net-url { + min-width: 0; + flex: 1; + word-break: break-all; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-net-url.is-failed { + color: var(--color-alert); +} + +/* screenshot body */ +[data-iii-ui="browser"] .br-ui-shot { + padding: 12px; +} +[data-iii-ui="browser"] .br-ui-shot-img { + display: block; + max-width: 100%; + max-height: 320px; + border: 1px solid var(--color-rule); +} + +/* --- infra error view ------------------------------------------------ */ +[data-iii-ui="browser"] .br-ui-err { + border-top: 1px solid var(--color-rule-2); + border-left: 2px solid var(--color-warn); + background: var(--color-bg); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +[data-iii-ui="browser"] .br-ui-err-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +[data-iii-ui="browser"] .br-ui-err-code { + border: 1px solid var(--color-warn); + padding: 2px 6px; +} +[data-iii-ui="browser"] .br-ui-err-type { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-err-msg { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--color-ink); + white-space: pre-wrap; + word-break: break-word; +} +[data-iii-ui="browser"] .br-ui-err-detail { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: var(--color-ink-faint); + white-space: pre-wrap; + word-break: break-word; +} +[data-iii-ui="browser"] .br-ui-err-note { + font-size: 12px; + line-height: 1.6; + color: var(--color-ink-faint); +} +[data-iii-ui="browser"] .br-ui-err-body { + font-size: 12.5px; + line-height: 1.6; + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-err-docs { + width: fit-content; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-accent); + text-decoration: none; +} +[data-iii-ui="browser"] .br-ui-err-docs:hover { + text-decoration: underline; +} +[data-iii-ui="browser"] .br-ui-code { + color: var(--color-ink); +} diff --git a/browser/ui/tsconfig.json b/browser/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/browser/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 1d636583e..c55fdb4ae 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -26,7 +26,6 @@ import { import { buildViewOptions } from '@/lib/nav-options' import { type RegisteredPage, useExtPages } from '@/lib/ui-slots' import { cn } from '@/lib/utils' -import { Browser } from '@/pages/Browser' import { Configuration } from '@/pages/Configuration' import { ExtPage } from '@/pages/Ext' import { Github } from '@/pages/Github' @@ -112,8 +111,6 @@ export function App() { ) : view === 'worktrees' ? ( - ) : view === 'browser' ? ( - ) : view === 'memory' ? ( ) : view === 'github' ? ( @@ -156,25 +153,17 @@ function Header({ onOpenShortcuts, }: HeaderProps) { // Optional-worker entries appear only while their worker is present; a - // direct #/worktrees or #/browser hit still lands on that page's install - // notice. - const { - worktreeAvailable, - browserAvailable, - memoryAvailable, - githubAvailable, - } = useConversationsCtx() + // direct #/worktrees hit still lands on that page's install notice. The + // browser page moved into the browser worker's injected UI (#/ext/browser), + // so it is gated by script presence, not a nav flag here. + const { worktreeAvailable, memoryAvailable, githubAvailable } = + useConversationsCtx() // Injected pages: the runtime analogue of worker-presence gating — // presence is the script being loaded, which already tracks worker // connectedness via trigger GC. const extPages = useExtPages() const viewOptions: { value: string; label: string }[] = [ - ...buildViewOptions( - worktreeAvailable, - browserAvailable, - memoryAvailable, - githubAvailable, - ), + ...buildViewOptions(worktreeAvailable, memoryAvailable, githubAvailable), ...extPages.map((page) => ({ value: extNavValue(page), label: page.title, diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index e68b7a4ec..5c51d1892 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -14,7 +14,6 @@ import { isChatBlockedByHarness, isHarnessAvailable, } from '@/hooks/use-harness-status' -import { hashForBrowserSession } from '@/hooks/use-hash-route' import { useLiveAnnouncer } from '@/hooks/use-live-announcer' import { useWorktreeBinding } from '@/hooks/use-worktree-binding' import { useWorktreeEvents } from '@/hooks/use-worktree-events' @@ -30,10 +29,6 @@ import type { CompactResult, QueuedMessagePreview, } from '@/lib/backend/types' -import { - BROWSER_SESSIONS_START_FUNCTION_ID, - browserSessionIdFromCall, -} from '@/lib/browser' import { useConversationsCtxOptional } from '@/lib/conversations-context' import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions' import { formatStopReason } from '@/lib/format-stop-reason' @@ -1466,55 +1461,6 @@ export function ChatView({ onLandBlocked: handleLandBlocked, }) - // Lightweight notice when a browser session starts in this conversation, - // pointing at the Browser tab. Sourced from the transcript itself (the - // worker's session-started trigger carries no conversation identity): the - // first pass over a hydrated transcript only seeds the seen-set, so - // reloads never re-announce old sessions. - const browserEnabled = - backend.id === 'real' && - (conversationsCtx ? conversationsCtx.browserAvailable : false) - const browserNoticesRef = useRef<{ seeded: boolean; seen: Set }>({ - seeded: false, - seen: new Set(), - }) - // biome-ignore lint/correctness/useExhaustiveDependencies: reset the tracker when the conversation changes - useEffect(() => { - browserNoticesRef.current = { seeded: false, seen: new Set() } - }, [conversation.id]) - useEffect(() => { - if (!browserEnabled) return - if (!conversation.draft && !conversation.hydrated) return - const tracker = browserNoticesRef.current - const fresh: string[] = [] - for (const m of conversation.messages) { - if (m.role !== 'function-trigger') continue - if (m.functionId !== BROWSER_SESSIONS_START_FUNCTION_ID) continue - if (m.running || m.pendingApproval) continue - if (tracker.seen.has(m.id)) continue - const browserSessionId = browserSessionIdFromCall(m.input, m.output) - if (!browserSessionId) continue - tracker.seen.add(m.id) - if (tracker.seeded) fresh.push(browserSessionId) - } - tracker.seeded = true - for (const browserSessionId of fresh) { - onAppendMessage( - conversation.id, - makeSystemNotice( - `browser session ${browserSessionId} started, watch it live in the browser tab (${hashForBrowserSession(browserSessionId)})`, - ), - ) - } - }, [ - browserEnabled, - conversation.messages, - conversation.id, - conversation.draft, - conversation.hydrated, - onAppendMessage, - ]) - // Re-scope the working directory. Allowed mid-conversation (no irreversible // lock); a change after the chat has started drops a visible marker so the // directory the agent operates in is never silently swapped. diff --git a/console/web/src/components/function-trigger/renderer-registry.tsx b/console/web/src/components/function-trigger/renderer-registry.tsx index 4afcfee27..104ed5718 100644 --- a/console/web/src/components/function-trigger/renderer-registry.tsx +++ b/console/web/src/components/function-trigger/renderer-registry.tsx @@ -10,10 +10,6 @@ */ import { useMemo } from 'react' -import { - BrowserFunctionIdLabel, - BrowserToolView, -} from '@/components/chat/browser' import { CoderFunctionIdLabel, CoderToolView } from '@/components/chat/coder' import { EngineFunctionIdLabel, EngineToolView } from '@/components/chat/engine' import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp' @@ -44,9 +40,10 @@ import type { FunctionTriggerMessage } from '@/types/chat' import type { FunctionTriggerRenderer } from '@/types/injectable-ui' /** - * The first-party families (12 since directory moved into its worker's injected UI), in the exact order of the old `??` chains. - * Each family's `tryRender*` already gates on its own function ids, so an - * entry returning `null` falls through to the next. + * The first-party families (11 since directory and browser moved into their + * workers' injected UI), in the exact order of the old `??` chains. Each + * family's `tryRender*` already gates on its own function ids, so an entry + * returning `null` falls through to the next. */ export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [ { @@ -147,14 +144,6 @@ export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [ tryRenderPreview: StateToolView.tryRenderPreview, FunctionIdLabel: StateFunctionIdLabel, }, - { - id: 'first-party/browser', - isMatch: BrowserToolView.isBrowserFunction, - tryRender: BrowserToolView.tryRender, - tryRenderRunning: BrowserToolView.tryRenderRunning, - tryRenderPreview: BrowserToolView.tryRenderPreview, - FunctionIdLabel: BrowserFunctionIdLabel, - }, ] /** diff --git a/console/web/src/hooks/use-browser-events.ts b/console/web/src/hooks/use-browser-events.ts deleted file mode 100644 index 35d2ed284..000000000 --- a/console/web/src/hooks/use-browser-events.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { useEffect, useId, useRef, useState } from 'react' -import { BROWSER_LIFECYCLE_TRIGGERS } from '@/lib/browser' -import { getIiiClient } from '@/lib/iii-client' - -/** - * Browser-local bindings to the browser worker's custom trigger types, - * following the worktree-events pattern: `client.on(fnId)` plus - * `client.registerTrigger` targeting `::`. The per-mount - * `instanceId` keeps two hook instances from colliding on the registered - * function name. - * - * Registration is wrapped in try/catch: with the worker absent (its trigger - * types unregistered) the binding drops silently; callers already gate - * `enabled` on browser presence, this is defense-in-depth for races. - */ - -const LIFECYCLE_FN = 'console::browser-lifecycle' - -export interface UseBrowserLifecycleEventsOptions { - /** Only subscribe on the real backend with the browser worker present. */ - enabled: boolean - /** Fired for every session lifecycle event, with its trigger type. */ - onEvent: (triggerType: string, payload: unknown) => void -} - -export interface BrowserLifecycleSubscription { - /** - * True once all three trigger bindings registered. While false (probe in - * flight, worker absent, SDK failure) callers fall back to polling. - */ - bound: boolean -} - -/** - * Page-scoped feed of the session lifecycle trigger types - * (session-started / session-stopped / navigated), for surfaces that - * re-read the session list on any change. - */ -export function useBrowserLifecycleEvents( - opts: UseBrowserLifecycleEventsOptions, -): BrowserLifecycleSubscription { - const { enabled } = opts - const onEventRef = useRef(opts.onEvent) - onEventRef.current = opts.onEvent - - const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') - const [bound, setBound] = useState(false) - - useEffect(() => { - if (!enabled) { - setBound(false) - return - } - let cancelled = false - const offs: Array<() => void> = [] - - void (async () => { - let client: Awaited> - try { - client = await getIiiClient() - } catch { - // Client startup failed; stay unbound so callers fall back to polling. - return - } - if (cancelled) return - let registered = 0 - for (const triggerType of BROWSER_LIFECYCLE_TRIGGERS) { - const suffix = triggerType.replace(/[^a-zA-Z0-9]/g, '-') - const localFnId = `${LIFECYCLE_FN}::${suffix}::${instanceId}` - try { - offs.push( - client.on(localFnId, (payload: unknown) => { - onEventRef.current(triggerType, payload) - }), - ) - offs.push( - client.registerTrigger({ - type: triggerType, - function_id: `${localFnId}::${client.browserId}`, - config: {}, - }), - ) - registered += 1 - } catch { - // Worker absent or trigger type unregistered; drop the binding. - } - } - if (!cancelled) { - setBound(registered === BROWSER_LIFECYCLE_TRIGGERS.length) - } - })() - - return () => { - cancelled = true - setBound(false) - for (const off of offs) off() - } - }, [enabled, instanceId]) - - return { bound } -} - -export interface UseBrowserSessionEventOptions { - /** Only subscribe on the real backend with the browser worker present. */ - enabled: boolean - /** Trigger type to bind (e.g. `browser::console-event`). */ - triggerType: string - /** Session the binding filters to (worker-side `session_id` filter). */ - sessionId: string | null - /** Base id for this binding's browser-local handler. */ - fnId: string - onEvent: (payload: unknown) => void -} - -/** - * One session-filtered binding to a browser trigger type (console-event or - * picked). Rebinds when the session changes and unregisters on unmount. - */ -export function useBrowserSessionEvent( - opts: UseBrowserSessionEventOptions, -): void { - const { enabled, triggerType, sessionId, fnId } = opts - const onEventRef = useRef(opts.onEvent) - onEventRef.current = opts.onEvent - - const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') - - useEffect(() => { - if (!enabled || !sessionId) return - let cancelled = false - const offs: Array<() => void> = [] - - void (async () => { - let client: Awaited> - try { - client = await getIiiClient() - } catch { - return - } - if (cancelled) return - const localFnId = `${fnId}::${instanceId}` - try { - offs.push( - client.on(localFnId, (payload: unknown) => { - onEventRef.current(payload) - }), - ) - offs.push( - client.registerTrigger({ - type: triggerType, - function_id: `${localFnId}::${client.browserId}`, - config: { session_id: sessionId }, - }), - ) - } catch { - // Worker absent or trigger type unregistered; drop the binding. - } - })() - - return () => { - cancelled = true - for (const off of offs) off() - } - }, [enabled, triggerType, sessionId, fnId, instanceId]) -} - -export interface UseBrowserStreamOptions { - enabled: boolean - /** iii stream name to subscribe to. */ - streamName: string - /** Stream group (the session id for per-session streams). */ - groupId: string | null - /** Base id for this binding's browser-local handler. */ - fnId: string - onFrame: (payload: unknown) => void -} - -/** - * Subscribe to an iii stream (`type:'stream'`) for a session, the same - * engine-pushes / client-appends pattern the Traces view uses. Rebinds when - * the stream group (session) changes and unregisters on unmount. - */ -export function useBrowserStream(opts: UseBrowserStreamOptions): void { - const { enabled, streamName, groupId, fnId } = opts - const onFrameRef = useRef(opts.onFrame) - onFrameRef.current = opts.onFrame - - const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') - - useEffect(() => { - if (!enabled || !groupId) return - let cancelled = false - const offs: Array<() => void> = [] - - void (async () => { - let client: Awaited> - try { - client = await getIiiClient() - } catch { - return - } - if (cancelled) return - const localFnId = `${fnId}::${instanceId}` - try { - offs.push( - client.on(localFnId, (payload: unknown) => { - onFrameRef.current(payload) - }), - ) - offs.push( - client.registerTrigger({ - type: 'stream', - function_id: `${localFnId}::${client.browserId}`, - config: { stream_name: streamName, group_id: groupId }, - }), - ) - } catch { - // Stream not available; the seed read is the fallback. - } - })() - - return () => { - cancelled = true - for (const off of offs) off() - } - }, [enabled, streamName, groupId, fnId, instanceId]) -} diff --git a/console/web/src/hooks/use-browser-status.ts b/console/web/src/hooks/use-browser-status.ts deleted file mode 100644 index 96055a282..000000000 --- a/console/web/src/hooks/use-browser-status.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - isWorkerPresent, - useWorkerPresence, - type WorkerPresence, -} from './use-worker-presence' - -/** - * Presence probe for the `browser` worker. Browser owns interactive - * Chromium sessions: the Browser page (viewport, console feed, pick-to-chat) - * and the chat's browser function-trigger views. It is OPTIONAL, so the console - * gates that whole surface on its presence rather than rendering controls - * that would call functions that don't exist. Thin wrapper over the generic - * worker-presence probe. - */ - -/** Engine worker name for the browser worker. */ -const BROWSER_WORKER_NAME = 'browser' -/** Base id for the browser-local handler bound to the `worker` trigger. */ -const BROWSER_WATCH_FN = 'console::browser-watch' - -export type BrowserStatus = WorkerPresence - -/** - * @param enabled - only run against the real backend; pass `false` for the - * mock/Storybook backend (treats browser as present so its UI shows in - * isolation). - */ -export function useBrowserStatus(enabled: boolean): BrowserStatus { - return useWorkerPresence({ - workerName: BROWSER_WORKER_NAME, - watchFnId: BROWSER_WATCH_FN, - enabled, - }) -} - -/** - * Whether the browser worker's session functions are registered and safe - * to trigger. False during the initial presence probe and while the worker - * is absent. - */ -export function isBrowserAvailable(status: BrowserStatus): boolean { - return isWorkerPresent(status) -} diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index 143e08951..7d1f35076 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -3,17 +3,17 @@ import { useCallback, useEffect, useRef, useState } from 'react' // `chat` is no longer a routed view; it's always-rendered as the side dock // in App.tsx. Hash routes only pick which view fills the right pane. The // component spec sheet + streaming playground moved to Storybook, so the -// routed views are `traces`, `workers`, `worktrees`, `browser`, and -// `configuration`. +// routed views are `traces`, `workers`, `worktrees`, and `configuration`. // `ext` is the injectable-UI prefix: worker-contributed pages route at // `#/ext/` — deliberately outside the first-party names so an // injected page can never collide with or shadow `#/traces`, `#/workers`, …. +// The browser page moved into the browser worker's injected UI +// (`#/ext/browser`), so it is no longer a first-party route here. export type View = | 'configuration' | 'traces' | 'workers' | 'worktrees' - | 'browser' | 'memory' | 'github' | 'ext' @@ -105,9 +105,6 @@ function routeFromHash(hash: string): View | null { if (hash === '#/github') { return 'github' } - if (hash === '#/browser' || hash.startsWith('#/browser/')) { - return 'browser' - } if (hash.startsWith('#/ext/')) { return 'ext' } @@ -137,8 +134,6 @@ function hashFor(view: View): string { return '#/workers' case 'worktrees': return '#/worktrees' - case 'browser': - return '#/browser' case 'memory': return '#/memory' case 'github': @@ -232,61 +227,6 @@ export function useExtPageRoute(): string | null { return selected } -const BROWSER_HASH = '#/browser' - -/** `#/browser/` -> the session id, or null for `#/browser`. */ -export function browserSessionFromHash(hash: string): string | null { - if (!hash.startsWith(`${BROWSER_HASH}/`)) return null - const segment = hash - .slice(BROWSER_HASH.length + 1) - .split('/') - .filter(Boolean)[0] - if (!segment) return null - return decodeSegment(segment) -} - -export function hashForBrowserSession(sessionId: string | null): string { - if (!sessionId) return BROWSER_HASH - return `${BROWSER_HASH}/${encodeURIComponent(sessionId)}` -} - -/** - * Selected browser session as a hash sub-route, so sessions deep-link - * (`#/browser/`) from chat cards and survive reloads. - */ -export function useBrowserSessionRoute(): [ - string | null, - (next: string | null) => void, -] { - const [selected, setSelected] = useState(() => { - if (typeof window === 'undefined') return null - return browserSessionFromHash(window.location.hash) - }) - const selectedRef = useRef(selected) - selectedRef.current = selected - - useEffect(() => { - const sync = () => { - const next = browserSessionFromHash(window.location.hash) - if (next !== selectedRef.current) setSelected(next) - } - sync() - window.addEventListener('hashchange', sync) - return () => window.removeEventListener('hashchange', sync) - }, []) - - const navigate = useCallback((next: string | null) => { - const targetHash = hashForBrowserSession(next) - if (window.location.hash !== targetHash) { - window.location.hash = targetHash - } else { - setSelected(next) - } - }, []) - - return [selected, navigate] -} - export function useWorkersConfigurationRoute(): [ WorkersConfigurationRoute, (configurationId: string | null, fieldPath?: string[]) => void, diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx index 1e3f722ae..19ed3d25f 100644 --- a/console/web/src/lib/conversations-context.tsx +++ b/console/web/src/lib/conversations-context.tsx @@ -9,10 +9,6 @@ import { isApprovalGateAvailable, useApprovalGateStatus, } from '@/hooks/use-approval-gate-status' -import { - isBrowserAvailable, - useBrowserStatus, -} from '@/hooks/use-browser-status' import { type ConversationsApi, useConversations, @@ -81,12 +77,6 @@ interface ConversationsContextValue extends ConversationsApi { * backend. */ worktreeAvailable: boolean - /** - * Whether the optional `browser` worker is connected. Gates the Browser - * page nav entry, its `browser::*` RPC, and the chat's browser - * session-start notices. Only meaningful on the real backend. - */ - browserAvailable: boolean /** * Whether the optional `memory` worker is connected. Gates the Memory * page nav entry and its `memory::*` RPC. Only meaningful on the real @@ -127,9 +117,6 @@ export function ConversationsProvider({ const worktreeAvailable = isWorktreeAvailable( useWorktreeStatus(backend.id === 'real'), ) - const browserAvailable = isBrowserAvailable( - useBrowserStatus(backend.id === 'real'), - ) const memoryAvailable = isMemoryAvailable( useMemoryStatus(backend.id === 'real'), ) @@ -183,7 +170,6 @@ export function ConversationsProvider({ approvalGateAvailable, shellAvailable, worktreeAvailable, - browserAvailable, memoryAvailable, githubAvailable, } diff --git a/console/web/src/lib/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts index cbe8430a2..d40b8b1a1 100644 --- a/console/web/src/lib/nav-options.test.ts +++ b/console/web/src/lib/nav-options.test.ts @@ -3,38 +3,43 @@ import { buildViewOptions } from './nav-options' describe('buildViewOptions', () => { it('hides the optional-worker entries while their workers are absent', () => { - expect( - buildViewOptions(false, false, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers']) + expect(buildViewOptions(false, false, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + ]) }) it('appends the worktrees entry when the worker is present', () => { - expect( - buildViewOptions(true, false, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'worktrees']) - }) - - it('appends the browser entry when the worker is present', () => { - expect( - buildViewOptions(false, true, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'browser']) + expect(buildViewOptions(true, false, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'worktrees', + ]) }) it('appends the memory entry when the worker is present', () => { - expect( - buildViewOptions(false, false, true, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'memory']) + expect(buildViewOptions(false, true, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'memory', + ]) }) it('appends the github entry when the worker is present', () => { - expect( - buildViewOptions(false, false, false, true).map((o) => o.value), - ).toEqual(['traces', 'workers', 'github']) + expect(buildViewOptions(false, false, true).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'github', + ]) }) it('appends every entry when all optional workers are present', () => { - expect( - buildViewOptions(true, true, true, true).map((o) => o.value), - ).toEqual(['traces', 'workers', 'worktrees', 'browser', 'memory', 'github']) + expect(buildViewOptions(true, true, true).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'worktrees', + 'memory', + 'github', + ]) }) }) diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts index fac2aecf2..a760aa906 100644 --- a/console/web/src/lib/nav-options.ts +++ b/console/web/src/lib/nav-options.ts @@ -8,7 +8,6 @@ import type { View } from '@/hooks/use-hash-route' */ export function buildViewOptions( worktreeAvailable: boolean, - browserAvailable: boolean, memoryAvailable: boolean, githubAvailable: boolean, ): { value: View; label: string }[] { @@ -19,9 +18,6 @@ export function buildViewOptions( if (worktreeAvailable) { options.push({ value: 'worktrees', label: 'worktrees' }) } - if (browserAvailable) { - options.push({ value: 'browser', label: 'browser' }) - } if (memoryAvailable) { options.push({ value: 'memory', label: 'memory' }) } diff --git a/console/web/src/pages/Browser/components/SessionRail.tsx b/console/web/src/pages/Browser/components/SessionRail.tsx deleted file mode 100644 index b46dc0154..000000000 --- a/console/web/src/pages/Browser/components/SessionRail.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Globe } from 'lucide-react' -import { formatMtime } from '@/components/chat/sandbox/format' -import type { BrowserSessionInfo } from '@/lib/browser' -import { cn } from '@/lib/utils' - -/** - * Left rail: one row per live Chromium session. Selection routes to - * `#/browser/` so the viewport deep-links and survives reloads. - */ - -interface SessionRailProps { - sessions: BrowserSessionInfo[] - selectedId: string | null - onSelect: (sessionId: string) => void -} - -function hostOf(url: string): string { - try { - const parsed = new URL(url) - return parsed.host || url - } catch { - return url - } -} - -export function SessionRail({ - sessions, - selectedId, - onSelect, -}: SessionRailProps) { - return ( - - ) -} diff --git a/console/web/src/pages/Browser/index.tsx b/console/web/src/pages/Browser/index.tsx deleted file mode 100644 index 5686d8fa8..000000000 --- a/console/web/src/pages/Browser/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { AlertCircle, Globe, Plus, RefreshCw } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' -import { Button } from '@/components/ui/Button' -import { EmptyState } from '@/components/ui/EmptyState' -import { StatusDot } from '@/components/ui/StatusDot' -import { StatusPanel } from '@/components/ui/StatusPanel' -import { - isBrowserAvailable, - useBrowserStatus, -} from '@/hooks/use-browser-status' -import { useBrowserSessionRoute } from '@/hooks/use-hash-route' -import { errorMessage, startBrowserSession } from '@/lib/browser' -import { useConversationsCtx } from '@/lib/conversations-context' -import { cn } from '@/lib/utils' -import { SessionRail } from './components/SessionRail' -import { SessionView } from './components/SessionView' -import { useBrowserSessionsLive } from './hooks/useBrowserSessionsLive' - -/** - * Live browser page: session rail -> polled viewport -> console/network - * feeds, so a user can watch what an agent is doing in a Chromium session - * and pick elements straight into chat. Data is `browser::sessions::list`, - * refreshed on the session lifecycle trigger types (poll fallback while - * bindings are unavailable). The nav entry is gated on worker presence; a - * direct hash hit with the worker absent lands on the install notice below. - */ - -export function Browser() { - const { backend } = useConversationsCtx() - const status = useBrowserStatus(backend.id === 'real') - const available = isBrowserAvailable(status) - - const { sessions, loading, error, live, refresh } = - useBrowserSessionsLive(available) - - const [selectedId, setSelectedId] = useBrowserSessionRoute() - const selected = useMemo( - () => sessions.find((s) => s.session_id === selectedId) ?? null, - [sessions, selectedId], - ) - - // Auto-select the first session when nothing (or a stopped session) is - // selected, and clear a deep link whose session is gone. - useEffect(() => { - if (!available || loading) return - if (selectedId && sessions.some((s) => s.session_id === selectedId)) return - const next = sessions[0]?.session_id ?? null - if (next !== selectedId) setSelectedId(next) - }, [available, loading, sessions, selectedId, setSelectedId]) - - const [starting, setStarting] = useState(false) - const [startError, setStartError] = useState(null) - const handleNewSession = async () => { - if (starting) return - setStarting(true) - try { - const started = await startBrowserSession() - setStartError(null) - refresh() - if (started) setSelectedId(started.session_id) - } catch (err) { - setStartError(errorMessage(err)) - } finally { - setStarting(false) - } - } - - const countLabel = loading ? '...' : String(sessions.length) - - return ( -
            -
            -
            -

            - browser -

            -

            - {available ? `${countLabel} sessions` : 'worker not connected'} -

            -
            - {available ? ( -
            - - - {live ? 'live' : 'polling'} - - - -
            - ) : null} -
            - - {!available ? ( -
            - {status.loading ? ( -

            - checking for the browser worker... -

            - ) : ( - } - headline="browser worker not installed" - detail="this page needs the optional browser worker. run: iii worker add browser" - /> - )} -
            - ) : error ? ( -
            - } - headline="failed to load browser sessions" - detail={error} - /> -
            - ) : !loading && sessions.length === 0 ? ( -
            - {startError ? ( - } - headline="could not start a session" - detail={startError} - className="mb-4" - /> - ) : null} - -
            - ) : ( -
            - - {selected ? ( - setSelectedId(null)} - /> - ) : ( -
            -

            - select a session -

            -
            - )} -
            - )} -
            - ) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84faee36f..71af82abb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,25 @@ importers: .: {} + browser/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + console/ui: dependencies: '@iii-dev/console-ui': @@ -179,14 +198,11 @@ importers: specifier: ^5.9.2 version: 5.9.3 - iii-directory/ui: + eval/ui: dependencies: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@types/react': specifier: ^19.2.14 @@ -198,11 +214,14 @@ importers: specifier: ^5.9.2 version: 5.9.3 - eval/ui: + iii-directory/ui: dependencies: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/react': specifier: ^19.2.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 83176624d..d7941af89 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - packages/* - console/web - console/ui + - browser/ui - database/ui - eval/ui - state/ui