localharness 0.78.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
//! SURFACE — the main-thread DOM half of the display: canvas mount + overlay
//! chrome, pointer/touch state, embed-card plumbing, and the broadcast-composer
//! UI. The cartridge itself runs off-thread (see [`super::worker`]); this
//! module owns everything it draws INTO and the input it polls FROM.

use std::cell::{Cell, RefCell};

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};

use crate::app::{dom, templates};

use super::{bridge, worker, FB_H, FB_W};

thread_local! {
    /// Latest cursor position in framebuffer coordinates. Updated by the
    /// delegated `mousemove` listener (see `events.rs`), forwarded to the
    /// worker (which owns the cartridge's `pointer_*` host imports). Poll model.
    static POINTER: Cell<(i32, i32)> = const { Cell::new((0, 0)) };
    /// 1 while the primary mouse button is down over the canvas. Updated
    /// by the delegated mousedown/mouseup listeners, forwarded to the worker.
    static POINTER_DOWN: Cell<i32> = const { Cell::new(0) };
    /// The DOM id of the canvas the CURRENTLY-RUNNING cartridge draws into.
    /// v1 is single-worker (one cartridge active at a time, OVERLAY or an
    /// inline embed), so pointer input maps relative to THIS canvas's rect.
    /// `run_with_ctx` sets it on each launch; defaults to the overlay canvas.
    /// (Concurrent embeds — a per-canvas worker registry — would replace this
    /// single id with per-worker input routing; see `run_in_canvas`.)
    static ACTIVE_CANVAS_ID: RefCell<String> = RefCell::new(String::from("display-canvas"));
}

/// Record which canvas the current cartridge launch owns (pointer routing).
/// Called by `run_with_ctx` / `mount_composition` on each launch.
///
/// Also RETIRES the inline card this launch supersedes (telemetry #79: running
/// an app inline and then publishing it left "duplicate UI cards"). Only one
/// cartridge runs at a time — the launch below terminates the previous worker —
/// so the older card would linger as a dead black canvas showing the same app.
/// DOM-only: the worker now belongs to the new run, so there is nothing to stop.
/// The fullscreen overlay is deliberately exempt in BOTH directions: its
/// [fullscreen] button moves a card's cartridge to `display-canvas` on purpose
/// and the card has to survive the return trip.
pub(super) fn set_active_canvas(canvas_id: &str) {
    let previous = ACTIVE_CANVAS_ID.with(|c| c.borrow().clone());
    if previous != canvas_id && is_embed_canvas_id(&previous) && is_embed_canvas_id(canvas_id) {
        retire_embed_card(&previous);
    }
    ACTIVE_CANVAS_ID.with(|c| *c.borrow_mut() = canvas_id.to_string());
}

/// An inline transcript embed card's canvas (as opposed to the fullscreen
/// overlay's `display-canvas`).
fn is_embed_canvas_id(id: &str) -> bool {
    id.starts_with("embed-canvas")
}

/// Swap an embed card for an inert same-id placeholder. Shared by the user's
/// [close] ([`close_embed`]) and the supersede path above, so a dismissed card
/// and a retired one leave the transcript in exactly the same state — and a
/// second close (or a replay swap) is a harmless no-op either way.
fn retire_embed_card(canvas_id: &str) {
    let card_id = crate::app::templates::embed_card_id(canvas_id);
    crate::app::dom::swap_outer(
        &card_id,
        &format!("<div id=\"{card_id}\" class=\"embed-card-closed\"></div>"),
    );
}

/// Clear the primary-button state (a fresh cartridge starts with no input).
pub(super) fn reset_pointer_down() {
    POINTER_DOWN.with(|d| d.set(0));
}

/// Dismiss an inline cartridge embed (telemetry #66 — a rendered embed had no
/// close control at all). Stops the cartridge ONLY when this canvas owns the
/// live worker: embeds share one worker slot, so a later cartridge may already
/// have superseded this one, and stopping then would kill the wrong run. The
/// card is replaced by an empty same-id placeholder, so a second close (or a
/// replay swap) is a harmless no-op.
pub(crate) fn close_embed(canvas_id: &str) {
    let owns_worker = ACTIVE_CANVAS_ID.with(|c| *c.borrow() == canvas_id);
    if owns_worker {
        super::stop();
    }
    retire_embed_card(canvas_id);
}

/// Is a drag in progress? `POINTER_DOWN` is only ever set by a press that
/// STARTED on a cartridge canvas, so this doubles as "the cartridge owns the
/// pointer right now" — a drag that wanders off the canvas keeps tracking.
pub(crate) fn pointer_is_down() -> bool {
    POINTER_DOWN.with(|d| d.get()) == 1
}

/// Should a move event at `target_id` reach the cartridge? Only when it is OVER
/// a cartridge canvas, or continues a drag that began on one. Moves used to
/// forward whenever a canvas existed ANYWHERE in the document, so scrolling or
/// swiping the transcript drove the cartridge's pointer from outside it — an
/// unfocused embed reacting to the page around it (telemetry #65).
pub(crate) fn should_track_move(target_id: &str) -> bool {
    is_cartridge_canvas_id(target_id) || pointer_is_down()
}

/// Update the primary-button state from mousedown/mouseup over the
/// canvas. Called from the delegated listeners in `events.rs`.
pub(crate) fn set_pointer_down(down: bool) {
    POINTER_DOWN.with(|d| d.set(if down { 1 } else { 0 }));
    forward_pointer_to_worker();
}

/// Forward the latest pointer state (position + button) to the cartridge
/// worker if one is live. Every cartridge path — single, embed, and `?compose=`
/// — runs off-thread, so the `pointer_*` host imports read cells INSIDE the
/// worker; we keep them fresh by posting on every pointer event. The worker's
/// `?compose=` loop hit-tests this pointer to the child under it (focus-gated).
/// No-op when no worker is active.
fn forward_pointer_to_worker() {
    if worker::is_active() {
        let (x, y) = POINTER.with(|p| p.get());
        let down = POINTER_DOWN.with(|d| d.get());
        worker::post_input(x, y, down);
    }
}

/// Update the cursor position from a `mousemove` over the canvas. Maps
/// client (CSS-pixel) coordinates to framebuffer coordinates using the
/// canvas's displayed rect, so cartridges see logical pixels regardless
/// of how the canvas is scaled. Called from the delegated listener in
/// `events.rs`.
pub(crate) fn set_pointer(client_x: f64, client_y: f64) {
    // Map relative to the ACTIVE cartridge's canvas — the overlay
    // `#display-canvas` for a fullscreen run, or an inline `#embed-canvas`
    // for an `embed_app` card. v1 is single-worker, so exactly one canvas is
    // the live cartridge's at a time (`run_with_ctx` records its id).
    let active_id = ACTIVE_CANVAS_ID.with(|c| c.borrow().clone());
    let Some(el) = dom::by_id(&active_id) else { return };
    let Ok(canvas) = el.dyn_into::<HtmlCanvasElement>() else { return };
    // The LIVE rect carries both the canvas's current page OFFSET (left/top —
    // robust to the container sitting anywhere in the layout) and its RENDERED
    // size (width/height — what CSS letterboxing scaled it to). Reading it every
    // event means a moved/resized/letterboxed canvas never desyncs the map.
    let rect = canvas.get_bounding_client_rect();
    let (rect_w, rect_h) = (rect.width(), rect.height());
    if rect_w <= 0.0 || rect_h <= 0.0 {
        return;
    }
    // Map into the canvas's ACTUAL backing-store resolution — the cartridge's
    // declared framebuffer dims (which `blit_frame` set on the first frame), NOT
    // the fixed default. A 512×512 cartridge sees pointer coords in 512×512
    // space. Fall back to the default before the first frame sizes the canvas.
    let fb_w = if canvas.width() > 0 { canvas.width() } else { FB_W };
    let fb_h = if canvas.height() > 0 { canvas.height() } else { FB_H };
    // framebuffer_x = (clientX - rect.left) * (fb_width / rect.width); same for y.
    // (clientX - rect.left) is the cursor's offset INTO the rendered canvas and
    // (fb / rect) is the rendered→framebuffer scale, so together they undo any
    // page offset AND any letterbox scaling.
    let fx = ((client_x - rect.left()) * (fb_w as f64 / rect_w)).clamp(0.0, (fb_w - 1) as f64) as i32;
    let fy = ((client_y - rect.top()) * (fb_h as f64 / rect_h)).clamp(0.0, (fb_h - 1) as f64) as i32;
    POINTER.with(|p| p.set((fx, fy)));
    forward_pointer_to_worker();
}

thread_local! {
    /// The bytes of the most-recently-launched inline cartridge, kept so the
    /// inline card's [fullscreen] button (`Action::RunInDisplay`) can relaunch
    /// the SAME cartridge into the fullscreen overlay. Session-lived; cleared
    /// on nothing (a new run overwrites it).
    static LAST_CARTRIDGE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
}

/// Stash the most-recently-run cartridge so the inline card's [fullscreen]
/// button can relaunch the SAME bytes into the overlay (issue #52a).
pub(super) fn remember_last_cartridge(wasm: &[u8]) {
    LAST_CARTRIDGE.with(|c| *c.borrow_mut() = Some(wasm.to_vec()));
}

/// The inline card's [fullscreen] button: mount the display overlay and
/// relaunch the most-recently-run inline cartridge into it. No-op (just opens
/// an idle overlay) when nothing has run yet. Wired from `Action::RunInDisplay`.
pub(crate) async fn relaunch_last_in_fullscreen() {
    let Some(wasm) = LAST_CARTRIDGE.with(|c| c.borrow().clone()) else {
        // Nothing to relaunch — open an idle framebuffer surface instead.
        crate::app::opfs::toggle_display();
        return;
    };
    if let Err(e) = super::run_wasm(&wasm).await {
        embed_trace(&format!("fullscreen relaunch failed: {e:?}"));
    }
}

/// How many undrained stashes to keep. Every stash pairs with exactly one
/// drain, so the queue normally holds 0–1 entries; the cap only bounds memory
/// if a future tool ever stashes without producing an embedding result.
const MAX_PENDING_EMBEDS: usize = 4;

thread_local! {
    /// Cartridge bytes a tool fetched or built, waiting for the chat transcript
    /// to paint the embed card they launch into. The tool can't draw the card
    /// itself (the `#tool-{id}-card` slot is filled by `chat::stream_turn`
    /// AFTER the tool returns), so it stashes the wasm here and the ToolResult
    /// handler drains it via [`launch_pending_embed`] once the canvas exists.
    /// NOT serialized into history — replay paints a marker card only (no
    /// bytes, like the display snapshot thumb).
    ///
    /// A QUEUE, not one slot: the engine dispatches a turn's tool calls
    /// back-to-back into a buffer while the UI paints behind it, so two
    /// embedding tools in one turn used to race — the second stash overwrote
    /// the first, the first card launched the SECOND cartridge, and the second
    /// card found nothing and stayed permanently black. Stashes happen in call
    /// order and drains in result order, which are the same order (the very
    /// correlation `chat::stream_turn`'s `pending_tools` FIFO already relies
    /// on), so front-to-back pairing is exact.
    static PENDING_EMBEDS: RefCell<std::collections::VecDeque<Vec<u8>>> =
        const { RefCell::new(std::collections::VecDeque::new()) };
}

/// Stash cartridge `wasm` for the next embed card to pick up. Tools call this
/// just before returning their embedding result (`{embedded:true}`, `{status}`,
/// `{url}`); `launch_pending_embed` (run from the ToolResult handler) drains
/// the matching entry.
pub(crate) fn stash_pending_embed(wasm: Vec<u8>) {
    PENDING_EMBEDS.with(|c| {
        let mut queue = c.borrow_mut();
        if queue.len() >= MAX_PENDING_EMBEDS {
            queue.pop_front();
        }
        queue.push_back(wasm);
    });
}

/// Drop any undrained stashes at the start of a user request. A turn cancelled
/// between a tool's stash and its card's paint would otherwise leave an orphan
/// at the front of the queue and shift every later pairing by one.
pub(crate) fn reset_pending_embeds() {
    PENDING_EMBEDS.with(|c| c.borrow_mut().clear());
}

thread_local! {
    /// Monotonic suffix for embed-canvas DOM ids. Every embed card —
    /// live OR history-replayed — must carry a UNIQUE canvas id: when all
    /// cards shared `#embed-canvas`, `by_id` resolved to the OLDEST card
    /// (often a dead replayed one at the top of the transcript), the
    /// cartridge launched into THAT, and the new card stayed a blank
    /// default-size canvas — the embed_app blank-render bug.
    static EMBED_CANVAS_SEQ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}

/// A fresh unique DOM id for an embed card's canvas (`embed-canvas-<n>`).
pub(crate) fn next_embed_canvas_id() -> String {
    EMBED_CANVAS_SEQ.with(|c| {
        let n = c.get().wrapping_add(1);
        c.set(n);
        format!("embed-canvas-{n}")
    })
}

/// If an `embed_app` tool stashed cartridge bytes AND the transcript has
/// painted its embed card, launch the cartridge into THAT CARD's canvas (the
/// inline interactive card). `card_id` is the `#tool-{id}-card` slot the card
/// just swapped into — scoping the lookup there (instead of a global id)
/// guarantees the cartridge lands in the card the user is looking at, not an
/// older embed's canvas. No-op when nothing is pending. Drains the stash
/// either way so a missing canvas can't leak bytes into a later embed.
pub(crate) async fn launch_pending_embed(card_id: &str) {
    let Some(wasm) = PENDING_EMBEDS.with(|c| c.borrow_mut().pop_front()) else {
        embed_trace(&format!("no-stash for #{card_id}"));
        return;
    };
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
    let Ok(Some(el)) = doc.query_selector(&format!("#{card_id} canvas.embed-app-canvas")) else {
        embed_trace(&format!("no-canvas inside #{card_id}"));
        return;
    };
    let Ok(canvas) = el.dyn_into::<HtmlCanvasElement>() else { return };
    match super::run_in_canvas(canvas, &wasm).await {
        Ok(()) => embed_trace(&format!("launched into #{card_id}")),
        Err(e) => embed_trace(&format!("run failed in #{card_id}: {e:?}")),
    }
}

/// Last embed-launch outcome, exposed at `window.__lhEmbedTrace` (and the
/// console) — the launch path's failure branches are otherwise silent in the
/// UI, and console capture is flaky under automation. One line, overwritten
/// per launch; costs nothing and makes "the embed is blank" diagnosable live.
fn embed_trace(msg: &str) {
    web_sys::console::warn_1(&JsValue::from_str(&format!("[embed] {msg}")));
    let _ = js_sys::Reflect::set(
        &js_sys::global(),
        &JsValue::from_str("__lhEmbedTrace"),
        &JsValue::from_str(msg),
    );
}

/// `true` if `id` is the DOM id of a cartridge canvas the delegated pointer
/// listeners should route input from — the fullscreen overlay `display-canvas`
/// or an inline `embed-canvas-<n>` (an `embed_app` card). Used by `events::mod`
/// to gate `set_pointer`/`set_pointer_down` on a pointer event's target.
pub(crate) fn is_cartridge_canvas_id(id: &str) -> bool {
    id == "display-canvas" || id.starts_with("embed-canvas")
}

// `cartridge_canvas_present` (a bare "is a canvas mounted ANYWHERE?" check) is
// gone: it was the move listeners' gate, and a document-wide presence test is
// exactly why events outside an embed drove it (telemetry #65). Moves now ask
// `should_track_move` WHERE the event landed.

/// Mount the display overlay (fullscreen, dismissable — the unified
/// stream's display surface) with a fresh canvas, then size + grab its
/// 2D context. Re-mounting over an already-open overlay just swaps in a
/// fresh surface, mirroring the old re-swap-the-panel behavior.
pub(super) fn mount_canvas() -> Result<CanvasRenderingContext2d, JsValue> {
    dom::swap_outer("display-overlay", &templates::display_overlay().into_string());
    size_and_get_ctx()
}

/// Snapshot the live `#display-canvas` as a PNG data URL — used by the
/// inline display card in the transcript. `None` when no canvas is mounted
/// or the encode fails. Cheap: the backing store is the cartridge's logical
/// framebuffer (512x512 default, up to 1024² for a `dims()`-declared
/// cartridge), so the PNG is at most a few hundred KB.
pub(crate) fn snapshot_data_url() -> Option<String> {
    let canvas = dom::by_id("display-canvas")?
        .dyn_into::<HtmlCanvasElement>()
        .ok()?;
    canvas.to_data_url().ok()
}

/// Size the existing `#display-canvas` backing store to the logical
/// framebuffer and return its 2D context. Assumes the canvas is already
/// in the DOM.
pub(super) fn size_and_get_ctx() -> Result<CanvasRenderingContext2d, JsValue> {
    let canvas = dom::by_id("display-canvas")
        .ok_or_else(|| JsValue::from_str("display-canvas missing"))?
        .dyn_into::<HtmlCanvasElement>()?;
    canvas.set_width(FB_W);
    canvas.set_height(FB_H);

    let ctx = canvas
        .get_context("2d")?
        .ok_or_else(|| JsValue::from_str("no 2d context"))?
        .dyn_into::<CanvasRenderingContext2d>()?;
    Ok(ctx)
}

/// host_agent::broadcast_compose — swap the composer panel in over the
/// canvas so the presser can type a custom message before it goes out (a
/// cartridge is pixels-only; only a real `<input>` summons a mobile
/// keyboard). Focuses + selects the prefilled default so typing replaces it.
pub(super) fn open_broadcast_composer(title: &str, default_body: &str) {
    dom::swap_outer(
        "broadcast-composer",
        &templates::broadcast_composer(title, default_body).into_string(),
    );
    if let Some(input) = dom::by_id("broadcast-input")
        .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
    {
        let _ = input.focus();
        input.select();
    }
}

/// True while the composer panel is swapped in (events uses this to route
/// Escape to the composer before the display overlay).
pub(crate) fn broadcast_composer_open() -> bool {
    dom::by_id("broadcast-composer")
        .map(|el| !el.has_attribute("hidden"))
        .unwrap_or(false)
}

/// The composer's [cancel] (and Escape): dismiss without sending.
pub(crate) fn close_broadcast_composer() {
    dom::swap_outer(
        "broadcast-composer",
        &templates::broadcast_composer_closed().into_string(),
    );
}

/// The composer's [send]: broadcast the typed body under `title` (the
/// cartridge-supplied title rides the button's `data-arg`), then close.
/// An emptied input still sends — the title alone is a valid ding.
pub(crate) fn broadcast_send(title: String) {
    let body: String = dom::by_id("broadcast-input")
        .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
        .map(|i| i.value())
        .unwrap_or_default()
        .trim()
        .chars()
        .take(200)
        .collect();
    close_broadcast_composer();
    if title.is_empty() {
        return;
    }
    wasm_bindgen_futures::spawn_local(bridge::feed::do_feed_broadcast(title, body));
}