diff --git a/.env.example b/.env.example index cd14465fd..750621e21 100644 --- a/.env.example +++ b/.env.example @@ -174,6 +174,18 @@ OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS=10 # QUERIT_API_KEY= # OPENHUMAN_QUERIT_API_KEY= +# --------------------------------------------------------------------------- +# Exa search (direct API, BYOK — https://exa.ai) +# --------------------------------------------------------------------------- +# [optional] API key from https://exa.ai. Select "Exa" in +# Settings > Search engine, or set OPENHUMAN_SEARCH_ENGINE=exa. Calls go +# straight to https://api.exa.ai with this key (never through the managed +# backend) and power web_search_tool plus exa_search / exa_find_similar / +# exa_get_contents. Engine silently falls back to "managed" if no key is +# present, so the agent always has working search. +# EXA_API_KEY= +# OPENHUMAN_EXA_API_KEY= + # --------------------------------------------------------------------------- # Brave search (direct API — https://brave.com/search/api/) # --------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index 503409361..20997f2f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,18 @@ Embedded provider webviews **must not** grow new JS injection. No new `.js` unde ## Rust core (`src/`) +### TinyHumans backend SDK boundary + +- All Rust-core calls to the TinyHumans managed backend must go through + `tinyhumans-sdk`, with OpenHuman adapters retaining local egress, budget, + session-expiry, TLS, and observability policy. +- Do not add direct `reqwest` calls for TinyHumans JSON APIs or duplicate SDK + wire types in OpenHuman. Extend the SDK and update its pinned revision when a + public route or type is missing. +- Never expose or call TinyHumans admin or webhook APIs from the SDK boundary. + Local inbound webhook routing is an OpenHuman runtime feature and is not an + exception for backend `/webhooks/*` calls. + ### Domain layout (`src/openhuman/`) ~130 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent`, `agent_experience`, `agent_meetings`, `agent_memory`, `agent_orchestration`, `agent_registry`, `agent_tool_policy`, `agentbox`, `orchestration`), memory (`memory`, `memory_archivist`, `memory_conversations`, `memory_diff`, `memory_goals`, `memory_queue`, `memory_search`, `memory_sources`, `memory_store`, `memory_sync`, `memory_tools`, `memory_tree`, `tinycortex`), skills/flows (`skills`, `skill_registry`, `skill_runtime`, `flows`, `tinyflows`, `tinyagents`, `rhai_workflows`), inference/AI (`inference`, `embeddings`, `routing`), MCP (`mcp_audit`, `mcp_client`, `mcp_registry`, `mcp_server`), runtimes (`runtime_node`, `runtime_python`, `runtime_python_server`, `javascript`, `sandbox`, `cwd_jail`), channels/webviews (`channels`, `whatsapp_data`), meet (`meet`, `meet_agent`), web3 (`wallet`, `web3`, `x402`, `tokenjuice`), plus platform domains (`about_app`, `approval`, `config`, `cron`, `credentials`, `keyring`, `security`, `threads`, `tools`, `update`, `voice`, …). diff --git a/Cargo.lock b/Cargo.lock index 28838a0ae..47f586cb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.63.3" +version = "0.63.5" dependencies = [ "aes-gcm", "aho-corasick", @@ -4750,6 +4750,7 @@ dependencies = [ "tinychannels", "tinycortex", "tinyflows", + "tinyhumans-sdk", "tinyjuice", "tinyplace", "tokio", @@ -7439,6 +7440,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinyhumans-sdk" +version = "0.1.0" +source = "git+https://github.com/tinyhumansai/sdk.git?rev=3ee4123ba3b7a76c5f167d7bc2c72fca86671292#3ee4123ba3b7a76c5f167d7bc2c72fca86671292" +dependencies = [ + "base64 0.22.1", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tinyjuice" version = "0.2.1" @@ -7464,7 +7479,7 @@ dependencies = [ [[package]] name = "tinyplace" -version = "2.0.2" +version = "2.0.4" dependencies = [ "aes", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 7050d5eff..0c2f503d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.63.3" +version = "0.63.5" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false @@ -144,6 +144,7 @@ dhat = { version = "0.3", optional = true } # AV root CAs. So the two TLS backends coexist only on Windows, never on # the RAM-sensitive platforms. reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] } +tinyhumans-sdk = { git = "https://github.com/tinyhumansai/sdk.git", rev = "3ee4123ba3b7a76c5f167d7bc2c72fca86671292" } # Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes` # can return `bytes::Bytes` without a copy. bytes = "1" @@ -290,11 +291,11 @@ ppt-rs = { version = "0.2.14", optional = true } # drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 # re-exports crossterm 0.29, so declaring crossterm directly at the same major # unifies to a single crossterm in the dep graph. Only compiled into -# `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. +# `src/tui/` behind `#[cfg(feature = "tui")]`. ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } # Terminal column-width measurement for the `tui` chat renderer -# (`src/openhuman/tui/render.rs`); only compiled behind `#[cfg(feature = "tui")]`. +# (`src/tui/render.rs`); only compiled behind `#[cfg(feature = "tui")]`. unicode-width = { version = "0.2", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively @@ -380,7 +381,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla-local"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla"] # HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and # its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` # OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file @@ -564,31 +565,38 @@ crash-reporting = ["dep:sentry"] # Tauri UI and never needs a terminal one) — see the allowlist entry in # `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via # `--no-default-features --features ""`, which drops -# the exclusive `ratatui` + `crossterm` dependencies. The module -# `src/openhuman/tui/` is a facade (always compiled) whose behavioural submodules -# are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a -# build-fact "tui feature disabled at compile time" error from the untouched -# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). +# the exclusive `ratatui` + `crossterm` dependencies. The crate-root +# `src/tui/` module is declared only under `#[cfg(feature = "tui")]`; when off, +# the always-compiled CLI dispatcher returns a build-fact "tui feature disabled +# at compile time" error for the `"tui" | "chat"` commands. tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] -# Local Medulla brain (plan Flavor A, §3.1–§3.2): the `medulla_local` domain — -# a supervised `medulla-serve` Node child speaking the serve NDJSON protocol, -# host-side inference + tools port callbacks, the `medulla_local` RPC namespace, -# and the subconscious-replacement draft (`subconscious.engine = "medulla"`, -# §5.2). Default-ON — the desktop app ships it — and therefore FORWARDED to the -# Tauri shell's `openhuman_core` features (default-features=false there). Slim / -# headless builds opt out via `--no-default-features --features ""`. The module is `#[cfg(feature = "medulla-local")]` at its -# `pub mod` declaration (a registration-site gate, like `flows`); the -# `subconscious.engine` config field stays compiled in ALL builds (inert serde), -# and the subconscious tick's medulla branch is `#[cfg]`-gated so the default -# (`local`) path is byte-identical whether or not this feature is on. -# Sheds ZERO exclusive dependencies — tokio net/process, reqwest, serde are all -# load-bearing for other domains; the value is compile-time surface. -# The serve transport is unix-only (unix domain sockets): on non-unix targets -# (Windows desktop) the feature still compiles via a supervisor stub that -# reports a typed unsupported-platform error, so forwarding the default-ON -# gate to the Tauri shell never breaks Windows packaging. -medulla-local = [] + +# Medulla integration: the HTTP/SSE client for the Medulla orchestration backend +# (`openhuman::medulla::client`) — durable sessions, event streaming, worker +# routing + roster, the operator task ledger and its GitHub sources, one-shot +# orchestration runs, the public feedback board, and the onboarding history +# reward. This is the real Medulla surface; it replaced the `medulla-local` +# supervised-child draft, which has been removed. +# +# Default-ON, but INTENTIONALLY NOT FORWARDED to the Tauri shell (allowlisted in +# scripts/lib/feature-forwarding.mjs, alongside `tui`): the desktop app is +# OpenHuman's own product and never dials a Medulla backend. The consumer is the +# Medulla TUI, which embeds this crate directly. +# +# Facade + type carve-out, NOT a leaf gate: `pub mod medulla;` is always +# compiled, and `medulla::contract` + `medulla::events` stay compiled in BOTH +# builds because `src/embed/` names those types in public signatures. Only +# `medulla::client` — everything that touches reqwest — is gated. This follows +# the rule the `skills` / `mcp_registry` gates established: carve inert types +# out and gate behaviour, so the two builds share one type definition and +# cannot drift. +# +# Sheds ZERO exclusive dependencies — do NOT "fix" the empty list. `reqwest`, +# `futures`, `serde`, and `tokio` are all load-bearing for other domains; the +# gate's value is compile-time surface and RPC/tool reach, not binary size. +# Verify before ever claiming otherwise: +# cargo tree -i reqwest --no-default-features --features tokenjuice-treesitter +medulla = [] # Channels domain: `openhuman::channels` (external-messaging providers — Telegram, # Discord, Slack, Signal, WhatsApp, iMessage, IRC, … — plus the channel runtime, diff --git a/README.md b/README.md index 807a50439..70dcb86b1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ OpenHuman is three things most assistants aren't: **a brain** that builds a pers ### 🔬 The deep researcher & doer - **[SuperContext](https://tinyhumans.gitbook.io/openhuman/features/super-context)**: a research scout sweeps your memory and files before the model reads your first message. No cold starts. -- **Batteries included**: web search, scraper, coder toolset, a real [browser](https://tinyhumans.gitbook.io/openhuman/features/native-tools/browser-and-computer), and [native voice](gitbooks/features/native-tools/voice.md) with in-process Whisper. [Model routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) picks the right LLM per workload on one subscription, with [local AI optional](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai). +- **Batteries included**: managed [web search](https://tinyhumans.gitbook.io/openhuman/features/native-tools/web-search), powered by [Exa](https://exa.ai), is included with your OpenHuman subscription and needs no API key; bring your own Exa key to search directly on your own Exa account and billing. Plus scraper, coder toolset, a real [browser](https://tinyhumans.gitbook.io/openhuman/features/native-tools/browser-and-computer), and [native voice](gitbooks/features/native-tools/voice.md) with in-process Whisper. [Model routing](https://tinyhumans.gitbook.io/openhuman/features/model-routing) picks the right LLM per workload on one subscription, with [local AI optional](https://tinyhumans.gitbook.io/openhuman/features/model-routing/local-ai). - **[Meeting agents](https://tinyhumans.gitbook.io/openhuman/features/mascot/meeting-agents)**: joins **Meet, Zoom, Teams, and Webex** with a face and a voice. It auto-joins from your calendar, streams a live transcript, answers by name, and files a summary with action items. - **[Image & video generation](https://tinyhumans.gitbook.io/openhuman/features/native-tools)**: Seedream/SeedEdit images and Seedance/Veo video, straight into your workspace on the same subscription. - **[17 messaging channels](https://tinyhumans.gitbook.io/openhuman/features/channels)**: Telegram, Discord, Slack, WhatsApp, Signal, iMessage… plus **native email** (IMAP IDLE + SMTP). Your agent reaches you where you already are. diff --git a/app/package.json b/app/package.json index 502ac06cb..3978c02ee 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.63.3", + "version": "0.63.5", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c008a97b2..b66022f50 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.63.3" +version = "0.63.5" dependencies = [ "anyhow", "async-trait", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.63.3" +version = "0.63.5" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 944704e22..a321f16fe 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.63.3" +version = "0.63.5" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" @@ -186,7 +186,6 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal # http://127.0.0.1:/rpc, so it REQUIRES the HTTP + Socket.IO transport # (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs. "http-server", - "medulla-local", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index da509c576..bd3d85adb 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3382,6 +3382,16 @@ pub fn run() { // / `center: false` for the main window so the placement // happens before the first paint and there's no jump. if let Some(window) = app.get_webview_window("main") { + // Layout first: mixed-DPI placement bugs (#5041) are not + // diagnosable from a user report without it, and logging + // before placement captures the pre-clamp state. + window_state::log_monitor_layout(&window); + // Installed before placement so a scale change triggered + // by our own cross-monitor move is caught too — on + // Windows that arrives via the message loop after + // `setup()` returns, which is why clamping here alone is + // not enough. + window_state::install_dpi_guard(&window); if !window_state::restore_main(&window) { window_state::center_main(&window); } diff --git a/app/src-tauri/src/window_state.rs b/app/src-tauri/src/window_state.rs index 462daf211..c4597c495 100644 --- a/app/src-tauri/src/window_state.rs +++ b/app/src-tauri/src/window_state.rs @@ -26,11 +26,32 @@ //! on small or scaled displays — see issue #2282. We also re-clamp on //! restore so a window saved on a large external display does not come //! back oversized after the user undocks onto a small laptop screen. +//! +//! ## Mixed-DPI multi-monitor (#5041) +//! +//! Every coordinate in this module is a **physical** pixel: +//! `Monitor::work_area()`, `outer_position()` and `outer_size()` all +//! speak physical, so there is no logical/physical conversion to get +//! wrong. The DPI hazard is elsewhere — Windows preserves a window's +//! *logical* size across a monitor change, so moving a window from a +//! 100 % display to a 150 % one makes the OS multiply its physical size +//! by 1.5 on arrival. A size that was correctly clamped to the +//! destination monitor before the move therefore arrives 1.5x too large. +//! +//! Two mitigations, because the OS rescale is asynchronous: +//! +//! 1. [`center_main`] targets the monitor the window is *already* on +//! (avoiding the cross-DPI move entirely), and when a move is +//! unavoidable it positions **before** sizing so the rescale lands +//! first and the applied size is measured against the destination. +//! 2. [`install_dpi_guard`] re-clamps on `ScaleFactorChanged`, which is +//! delivered through the message loop *after* `setup()` has returned +//! — the case no amount of startup clamping can catch. use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow}; +use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow, WindowEvent}; use crate::cef_profile; @@ -59,15 +80,23 @@ struct WindowState { height: u32, } -/// A monitor's usable work area in physical pixels. Plain-data struct so -/// the geometry math in [`clamp_to_work_area`] / [`pick_monitor_for_window`] -/// can be unit-tested without a live Tauri runtime. +/// A monitor's usable work area in physical pixels, plus its DPI scale +/// factor. Plain-data struct so the geometry math in +/// [`clamp_to_work_area`] / [`pick_monitor_for_window`] / +/// [`dpi_adjusted_size`] can be unit-tested without a live Tauri runtime. +/// +/// `scale` is only meaningful for cross-monitor moves on Windows — see +/// [`dpi_adjusted_size`]. All four geometry fields are physical pixels, +/// matching `Monitor::work_area()`, `outer_position()` and +/// `outer_size()`, so no logical/physical conversion happens anywhere in +/// this module. #[derive(Debug, Clone, Copy)] struct WorkArea { x: i32, y: i32, width: u32, height: u32, + scale: f64, } fn state_path() -> Option { @@ -184,13 +213,19 @@ pub fn restore_main(window: &WebviewWindow) -> bool { let (x, y, width, height) = clamp_to_work_area(state.x, state.y, state.width, state.height, monitor); - if let Err(err) = window.set_size(PhysicalSize::new(width, height)) { - log::warn!("[window-state] set_size failed: {err}"); - } + // Position before size (#5041). The saved geometry may belong to a + // monitor with a different scale factor than the one the window was + // just created on; moving first lets the OS apply its DPI rescale, + // so the size we then set is the size that sticks. The reverse order + // lets Windows multiply our just-applied size by the DPI ratio on + // arrival. `install_dpi_guard` still backstops the async case. if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) { log::warn!("[window-state] set_position failed: {err}"); return false; } + if let Err(err) = window.set_size(PhysicalSize::new(width, height)) { + log::warn!("[window-state] set_size failed: {err}"); + } if (x, y, width, height) != (state.x, state.y, state.width, state.height) { log::info!( "[window-state] restored geometry clamped to work area: saved x={} y={} w={} h={} -> applied x={} y={} w={} h={}", @@ -223,24 +258,92 @@ pub fn restore_main(window: &WebviewWindow) -> bool { /// not exceed the user's actual screen on small or scaled displays /// (issue #2282). pub fn center_main(window: &WebviewWindow) { - let Some(monitor) = primary_or_current_work_area(window) else { - let _ = window.center(); - return; - }; let Ok(size) = window.outer_size() else { let _ = window.center(); return; }; + let work_areas = collect_work_areas(window); + let primary = primary_or_current_work_area(window); + // Where the window currently sits drives monitor selection. With no + // readable position there is nothing to overlap-test, so go straight + // to the primary. + let target = match window.outer_position() { + Ok(pos) => target_work_area_for_new_window( + pos.x, + pos.y, + size.width, + size.height, + &work_areas, + primary, + ), + Err(err) => { + log::warn!("[window-state] outer_position unavailable ({err}); targeting primary"); + primary.or_else(|| work_areas.first().copied()) + } + }; + let Some(monitor) = target else { + let _ = window.center(); + return; + }; + + // Scale factor the window is living at *right now*. On a cross-DPI + // move the OS will rescale by `monitor.scale / current_scale`, so we + // need both halves of the ratio. + let current_scale = window + .current_monitor() + .ok() + .flatten() + .map(|m| m.scale_factor()) + .or_else(|| window.scale_factor().ok()) + .unwrap_or(monitor.scale); + + // Move onto the target monitor BEFORE committing a size (#5041). + // + // Ordering is load-bearing: `set_size` then `set_position` lets + // Windows rescale the just-applied size on arrival, which is exactly + // how a correctly-clamped window ends up 1.5x too wide. Positioning + // first lets the DPI change land, so the size we then apply is + // measured against the monitor the window is actually on. + if (monitor.scale - current_scale).abs() > f64::EPSILON { + let (predicted_w, predicted_h) = + dpi_adjusted_size(size.width, size.height, current_scale, monitor.scale); + log::info!( + "[window-state] cross-DPI placement: scale {} -> {}; size {}x{} would become {}x{} on arrival; positioning before sizing", + current_scale, + monitor.scale, + size.width, + size.height, + predicted_w, + predicted_h, + ); + if let Err(err) = window.set_position(PhysicalPosition::new(monitor.x, monitor.y)) { + log::warn!("[window-state] pre-size set_position failed: {err}"); + } + } + + // Re-read the size: on the cross-DPI path above the OS may already + // have rescaled us, so `size` is stale and clamping it would apply + // the wrong number. + let settled = window.outer_size().unwrap_or(size); + if (settled.width, settled.height) != (size.width, size.height) { + log::info!( + "[window-state] OS rescaled window during placement: {}x{} -> {}x{}", + size.width, + size.height, + settled.width, + settled.height, + ); + } // Resolve the new size first; if the default exceeds work area we // shrink before centering so the centered position is computed // against the actually-applied size, not the oversized default. - let (clamped_w, clamped_h) = clamp_size(size.width, size.height, &monitor); - if (clamped_w, clamped_h) != (size.width, size.height) { + let (clamped_w, clamped_h) = clamp_size(settled.width, settled.height, &monitor); + if (clamped_w, clamped_h) != (settled.width, settled.height) { log::info!( - "[window-state] default size {}x{} exceeds work area {}x{}; shrinking to {}x{}", - size.width, - size.height, + "[window-state] size {}x{} exceeds work area {}x{}; shrinking to {}x{}", + settled.width, + settled.height, monitor.width, monitor.height, clamped_w, @@ -267,22 +370,129 @@ pub fn center_main(window: &WebviewWindow) { } } +/// Re-clamp the window into its current monitor's work area after the OS +/// has changed its scale factor. +/// +/// `center_main` and `restore_main` both run inside Tauri's `setup()` +/// hook, but `WM_DPICHANGED` is delivered through the message loop +/// *after* `setup()` returns. Any size the OS imposes on arrival at a +/// different-DPI monitor therefore lands after our clamping has already +/// finished — the "clamping runs too late" half of #5041. This handler +/// is the durable fix: whenever the scale factor changes, re-fit the +/// window to whichever monitor it is now on. +/// +/// It also covers the case the startup path cannot: the user dragging +/// the window from a 100 % monitor onto a 150 % one at any point during +/// the session, where the same OS rescale applies. +fn reclamp_after_scale_change(window: &WebviewWindow, new_scale: f64) { + // Maximized and fullscreen windows intentionally carry geometry that can + // exceed the work area, and the OS owns it in those states. Clamping here + // would issue `set_size`/`set_position` that unmaximizes the window, or + // overwrite the bounds the OS restores on leaving fullscreen. Both states + // are reachable from the app's own `toggleMaximize()` and the native + // fullscreen menu, so this is a normal path, not an edge case. + // + // A failed query is treated as "not in that state": the pre-#5041 behaviour + // was to always clamp, so falling back to clamping keeps the fix working + // rather than silently disabling it on a backend that cannot answer. + if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) { + log::debug!( + "[window-state] scale change to {new_scale}; window is maximized/fullscreen — leaving geometry to the OS" + ); + return; + } + + let Ok(Some(monitor)) = window.current_monitor() else { + log::warn!("[window-state] scale change but current_monitor unavailable; skip re-clamp"); + return; + }; + let work_area = work_area_of(&monitor); + let Ok(pos) = window.outer_position() else { + log::warn!("[window-state] scale change but outer_position unavailable; skip re-clamp"); + return; + }; + let Ok(size) = window.outer_size() else { + log::warn!("[window-state] scale change but outer_size unavailable; skip re-clamp"); + return; + }; + + let (x, y, w, h) = clamp_to_work_area(pos.x, pos.y, size.width, size.height, work_area); + if (x, y, w, h) == (pos.x, pos.y, size.width, size.height) { + log::debug!( + "[window-state] scale change to {new_scale}; geometry {}x{} at ({},{}) already fits work area", + size.width, + size.height, + pos.x, + pos.y + ); + return; + } + + log::info!( + "[window-state] scale change to {new_scale}; re-clamping {}x{} at ({},{}) -> {}x{} at ({},{}) for work area ({},{} {}x{})", + size.width, + size.height, + pos.x, + pos.y, + w, + h, + x, + y, + work_area.x, + work_area.y, + work_area.width, + work_area.height, + ); + // Size before position: shrinking first means the subsequent move + // has a frame that already fits, so it cannot be pushed back out. + // `clamp_to_work_area` only ever shrinks and shifts within this one + // monitor, so this cannot bounce the window onto another display and + // re-trigger the handler. + if (w, h) != (size.width, size.height) { + if let Err(err) = window.set_size(PhysicalSize::new(w, h)) { + log::warn!("[window-state] set_size during scale-change re-clamp failed: {err}"); + } + } + if (x, y) != (pos.x, pos.y) { + if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) { + log::warn!("[window-state] set_position during scale-change re-clamp failed: {err}"); + } + } +} + +/// Subscribe the main window to scale-factor changes so it is re-fitted +/// whenever the OS moves it across a DPI boundary (#5041). +/// +/// Call once, from `setup()`, alongside `restore_main` / `center_main`. +pub fn install_dpi_guard(window: &WebviewWindow) { + let guarded = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::ScaleFactorChanged { scale_factor, .. } = event { + reclamp_after_scale_change(&guarded, *scale_factor); + } + }); + log::info!("[window-state] DPI guard installed on main window"); +} + +/// Project a live `Monitor` into the plain-data [`WorkArea`] the pure +/// geometry helpers operate on. Single conversion point so the +/// physical-pixel contract is stated once. +fn work_area_of(monitor: &tauri::Monitor) -> WorkArea { + let wa = monitor.work_area(); + WorkArea { + x: wa.position.x, + y: wa.position.y, + width: wa.size.width, + height: wa.size.height, + scale: monitor.scale_factor(), + } +} + fn collect_work_areas(window: &WebviewWindow) -> Vec { let Ok(monitors) = window.available_monitors() else { return Vec::new(); }; - monitors - .iter() - .map(|m| { - let wa = m.work_area(); - WorkArea { - x: wa.position.x, - y: wa.position.y, - width: wa.size.width, - height: wa.size.height, - } - }) - .collect() + monitors.iter().map(work_area_of).collect() } fn primary_or_current_work_area(window: &WebviewWindow) -> Option { @@ -291,13 +501,47 @@ fn primary_or_current_work_area(window: &WebviewWindow) -> Option .ok() .flatten() .or_else(|| window.current_monitor().ok().flatten())?; - let wa = monitor.work_area(); - Some(WorkArea { - x: wa.position.x, - y: wa.position.y, - width: wa.size.width, - height: wa.size.height, - }) + Some(work_area_of(&monitor)) +} + +/// Emit the full monitor layout once at startup. +/// +/// Mixed-DPI multi-monitor bugs (#5041) are effectively undebuggable from +/// a user report without knowing each monitor's origin, work area and +/// scale factor — the reporter's own numbers came from a third-party +/// tool and could not be reconciled afterwards. Logged at info so it +/// lands in the shipped log file. +pub fn log_monitor_layout(window: &WebviewWindow) { + let Ok(monitors) = window.available_monitors() else { + log::warn!("[window-state] available_monitors unavailable; cannot log layout"); + return; + }; + let primary_name = window + .primary_monitor() + .ok() + .flatten() + .and_then(|m| m.name().cloned()); + log::info!( + "[window-state] monitor layout: {} attached, primary={:?}", + monitors.len(), + primary_name + ); + for (idx, m) in monitors.iter().enumerate() { + let wa = m.work_area(); + let size = m.size(); + log::info!( + "[window-state] monitor[{}] name={:?} scale={} full={}x{} work_area=({},{} {}x{})", + idx, + m.name(), + m.scale_factor(), + size.width, + size.height, + wa.position.x, + wa.position.y, + wa.size.width, + wa.size.height, + ); + } } /// Return the work area whose intersection with the saved window rect @@ -339,6 +583,92 @@ fn pick_monitor_for_window( .map(|(_, wa)| wa) } +/// Pick the monitor a freshly-created window should be sized and centered +/// against. +/// +/// Preference order: +/// 1. The monitor the window is **actually on** (largest work-area +/// overlap). Staying put avoids a cross-monitor move entirely, which +/// on Windows is what triggers the DPI rescale described in +/// [`dpi_adjusted_size`]. +/// 2. The primary monitor, when the window overlaps nothing (Windows can +/// place a not-yet-shown window at `CW_USEDEFAULT`, off every work +/// area). +/// 3. Any attached monitor, so we never return `None` while monitors exist. +/// +/// The previous behaviour — always prefer the primary — is what made the +/// mixed-DPI case (#5041) reachable: a window created on a 100 % monitor +/// was clamped against the 150 % primary's work area and then moved +/// there, and Windows rescaled the just-applied size by 1.5 on arrival. +fn target_work_area_for_new_window( + x: i32, + y: i32, + width: u32, + height: u32, + work_areas: &[WorkArea], + primary: Option, +) -> Option { + pick_monitor_for_window(x, y, width, height, work_areas) + .or(primary) + .or_else(|| work_areas.first().copied()) +} + +/// Predict the physical size an OS will impose on a window that moves +/// between monitors with different DPI scale factors. +/// +/// This is the crux of #5041. Windows preserves a window's **logical** +/// size across a monitor change: on `WM_DPICHANGED` it multiplies the +/// physical size by `to_scale / from_scale`. So setting a physical size +/// that fits monitor B, and *then* moving the window from monitor A to +/// monitor B, does not leave the window at that size — it arrives +/// `to_scale / from_scale` times larger. +/// +/// Concretely, for the reporter's layout (secondary 1920×1080 @ 100 %, +/// primary 2560×1600 @ 150 %): a window correctly clamped to the +/// primary's 2560-px width while still sitting on the secondary arrives +/// at `2560 × 1.5 = 3840` physical pixels — wider than the 2560-px +/// monitor it was just fitted to, overflowing off the right edge. +/// +/// Returns the input unchanged when either scale is not a usable +/// positive, finite number, so a runtime reporting `0.0` or `NaN` can +/// never zero out or panic the window size. +fn dpi_adjusted_size(width: u32, height: u32, from_scale: f64, to_scale: f64) -> (u32, u32) { + let usable = |s: f64| s.is_finite() && s > 0.0; + if !usable(from_scale) || !usable(to_scale) { + return (width, height); + } + let ratio = to_scale / from_scale; + if !ratio.is_finite() || ratio <= 0.0 { + return (width, height); + } + let scaled = |v: u32| { + // Round rather than truncate. Truncation always loses, so a + // window shuttled between two monitors shrinks by up to a pixel + // per move and the error accumulates without bound. Rounding + // keeps it bounded at ±1 px total, however many moves happen. + // + // It is NOT an exact inverse in general. When the first hop is a + // *downscale* the intermediate size has genuinely lost + // information, and scaling back up cannot recover it: 1281 px at + // 2.0→1.25 gives 801, and 801 back at 1.25→2.0 gives 1282. The + // round trip is exact when the first hop scales up (the common + // case: a window sized on a low-DPI monitor moving to a high-DPI + // one and back). `dpi_adjusted_size_round_trips_without_drift` + // pins both halves of that contract. + let out = (f64::from(v) * ratio).round(); + // Saturate instead of wrapping — an absurd ratio must not + // produce a tiny window via u32 overflow. + if out >= f64::from(u32::MAX) { + u32::MAX + } else if out <= 0.0 { + 1 + } else { + out as u32 + } + }; + (scaled(width), scaled(height)) +} + /// Clamp width/height into the work area while preserving the /// `MIN_WINDOW_*` floors. Pure helper extracted from /// [`clamp_to_work_area`] so `center_main` can reuse it when the window @@ -401,14 +731,31 @@ mod tests { } fn wa(x: i32, y: i32, width: u32, height: u32) -> WorkArea { + wa_dpi(x, y, width, height, 1.0) + } + + /// Same as [`wa`] but with an explicit DPI scale factor, for the + /// mixed-DPI cases in #5041. + fn wa_dpi(x: i32, y: i32, width: u32, height: u32, scale: f64) -> WorkArea { WorkArea { x, y, width, height, + scale, } } + /// The reporter's layout from #5041: a 2560x1600 @ 150 % primary at + /// the virtual-desktop origin, and a 1920x1080 @ 100 % secondary + /// placed to its **left** (so the secondary's origin is negative). + /// Work areas subtract a plausible taskbar. + fn reporter_layout() -> (WorkArea, WorkArea) { + let primary = wa_dpi(0, 0, 2560, 1520, 1.5); + let secondary = wa_dpi(-1920, 0, 1920, 1032, 1.0); + (primary, secondary) + } + #[test] fn clamp_leaves_in_bounds_geometry_alone() { // 1280×800 work area, 1000×800 window centered-ish: width fits, @@ -536,6 +883,211 @@ mod tests { assert_eq!((m.x, m.width), (1920, 1280)); } + // ── Mixed-DPI multi-monitor (#5041) ────────────────────────────── + + #[test] + fn dpi_adjusted_size_grows_when_moving_to_a_higher_dpi_monitor() { + // The #5041 arithmetic: a window fitted to the 150 % primary's + // 2560 px work-area width while still sitting on the 100 % + // secondary arrives at 3840 px — wider than the very monitor it + // was just fitted to. This is the reported ~3875 px window. + let (grown_w, grown_h) = dpi_adjusted_size(2560, 1520, 1.0, 1.5); + assert_eq!(grown_w, 3840); + assert_eq!(grown_h, 2280); + } + + #[test] + fn dpi_adjusted_size_shrinks_when_moving_to_a_lower_dpi_monitor() { + let (w, h) = dpi_adjusted_size(3840, 2280, 1.5, 1.0); + assert_eq!((w, h), (2560, 1520)); + } + + #[test] + fn dpi_adjusted_size_is_identity_at_equal_scale() { + assert_eq!(dpi_adjusted_size(1280, 900, 1.5, 1.5), (1280, 900)); + assert_eq!(dpi_adjusted_size(1280, 900, 1.0, 1.0), (1280, 900)); + } + + #[test] + fn dpi_adjusted_size_round_trips_without_drift() { + // A→B→A must land back on the original size; truncating instead + // of rounding would lose a pixel on every move. + // + // Covers the whole standard DPI ladder rather than just 1.0↔1.5: + // a single pair does not establish the property (review, #5041). + // Odd dimensions are deliberate — even ones round-trip trivially. + const SIZES: [(u32, u32); 3] = [(1281, 901), (1920, 1080), (2560, 1600)]; + + // Upscale first: exact. This is the common case — a window sized + // on a low-DPI monitor moves to a high-DPI one and back. + for (from, to) in [ + (1.0, 1.25), + (1.0, 1.5), + (1.0, 1.75), + (1.0, 2.0), + (1.25, 1.5), + (1.5, 2.0), + ] { + for (w, h) in SIZES { + let (up_w, up_h) = dpi_adjusted_size(w, h, from, to); + let (back_w, back_h) = dpi_adjusted_size(up_w, up_h, to, from); + assert_eq!( + (back_w, back_h), + (w, h), + "upscale-first round trip {w}x{h} at {from}->{to}->{from} drifted to {back_w}x{back_h}" + ); + } + } + + // Downscale first: bounded, not exact. The intermediate size has + // genuinely lost information (1281 at 2.0->1.25 is 801, and 801 + // back up is 1282), so the contract is that the error stays + // within 1 px and never accumulates — which is precisely what + // truncation fails to do. + for (from, to) in [(2.0, 1.25), (1.75, 1.0), (2.0, 1.0), (1.5, 1.25)] { + for (w, h) in SIZES { + let (down_w, down_h) = dpi_adjusted_size(w, h, from, to); + let (back_w, back_h) = dpi_adjusted_size(down_w, down_h, to, from); + assert!( + back_w.abs_diff(w) <= 1 && back_h.abs_diff(h) <= 1, + "downscale-first round trip {w}x{h} at {from}->{to}->{from} drifted to {back_w}x{back_h}, beyond the 1px bound" + ); + } + } + } + + #[test] + fn dpi_adjusted_size_rejects_unusable_scale_factors() { + // A runtime reporting 0.0 / NaN / infinity must not zero out or + // panic the window size — return the input untouched instead. + for (from, to) in [ + (0.0, 1.5), + (1.5, 0.0), + (f64::NAN, 1.5), + (1.5, f64::NAN), + (f64::INFINITY, 1.5), + (1.5, f64::INFINITY), + (-1.0, 1.5), + ] { + assert_eq!( + dpi_adjusted_size(1280, 900, from, to), + (1280, 900), + "scale pair ({from}, {to}) must be treated as unusable" + ); + } + } + + #[test] + fn target_prefers_the_monitor_the_window_is_already_on() { + // Window created on the 100 % secondary (negative origin). + // Selecting the primary here is what forces the cross-DPI move + // that #5041 is about — we must stay put instead. + let (primary, secondary) = reporter_layout(); + let target = target_work_area_for_new_window( + -1800, + 100, + 1280, + 900, + &[primary, secondary], + Some(primary), + ) + .expect("a monitor should be selected"); + assert_eq!( + (target.x, target.width), + (secondary.x, secondary.width), + "window on the secondary must be sized against the secondary" + ); + assert!( + (target.scale - secondary.scale).abs() < f64::EPSILON, + "and must carry the secondary's scale factor" + ); + } + + #[test] + fn target_falls_back_to_primary_when_window_overlaps_nothing() { + // Windows can place a not-yet-shown window at CW_USEDEFAULT, + // off every work area. + let (primary, secondary) = reporter_layout(); + let target = target_work_area_for_new_window( + 50_000, + 50_000, + 1280, + 900, + &[primary, secondary], + Some(primary), + ) + .expect("should fall back to primary"); + assert_eq!((target.x, target.width), (primary.x, primary.width)); + } + + #[test] + fn target_falls_back_to_any_monitor_without_a_primary() { + let (primary, secondary) = reporter_layout(); + let target = + target_work_area_for_new_window(50_000, 50_000, 1280, 900, &[secondary, primary], None) + .expect("should fall back to the first attached monitor"); + assert_eq!(target.x, secondary.x); + } + + #[test] + fn target_returns_none_without_monitors() { + assert!(target_work_area_for_new_window(0, 0, 1280, 900, &[], None).is_none()); + } + + #[test] + fn clamp_then_move_overflows_but_move_then_clamp_fits() { + // End-to-end regression for #5041, expressed against the pure + // helpers so it runs without a Tauri runtime. + // + // Setup: an oversized window (larger than either work area) is + // created on the 100 % secondary and needs to end up on the + // 150 % primary. + let (primary, _secondary) = reporter_layout(); + let (created_w, created_h) = (3000, 1700); + + // OLD ordering — clamp to the destination, then move. The OS + // rescales by 1.5 on arrival and the result overflows the very + // monitor it was fitted to. + let (clamped_w, clamped_h) = clamp_size(created_w, created_h, &primary); + assert_eq!( + (clamped_w, clamped_h), + (2560, 1520), + "clamp itself is correct" + ); + let (arrived_w, arrived_h) = dpi_adjusted_size(clamped_w, clamped_h, 1.0, 1.5); + assert!( + arrived_w > primary.width && arrived_h > primary.height, + "old ordering must reproduce the oversized window: {arrived_w}x{arrived_h} vs work area {}x{}", + primary.width, + primary.height + ); + + // NEW ordering — move first (absorbing the OS rescale), then + // clamp against the destination. Result fits. + let (moved_w, moved_h) = dpi_adjusted_size(created_w, created_h, 1.0, 1.5); + let (final_w, final_h) = clamp_size(moved_w, moved_h, &primary); + assert!( + final_w <= primary.width && final_h <= primary.height, + "new ordering must fit the work area: {final_w}x{final_h} vs {}x{}", + primary.width, + primary.height + ); + } + + #[test] + fn reclamped_geometry_never_leaves_the_work_area_after_a_scale_change() { + // What `reclamp_after_scale_change` delegates to: whatever size + // the OS imposed on arrival, the window must end up wholly + // inside the destination work area. + let (primary, _) = reporter_layout(); + let (os_w, os_h) = dpi_adjusted_size(2560, 1520, 1.0, 1.5); + let (x, y, w, h) = clamp_to_work_area(0, 0, os_w, os_h, primary); + assert!(w <= primary.width && h <= primary.height); + assert!(x >= primary.x && y >= primary.y); + assert!(x.saturating_add(w as i32) <= primary.x.saturating_add(primary.width as i32)); + assert!(y.saturating_add(h as i32) <= primary.y.saturating_add(primary.height as i32)); + } + #[test] fn center_origin_after_min_floor_stays_in_work_area() { // Repro for the `center_main` edge case: a pathological work diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index eb3b24294..10a304ebc 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.63.3", + "version": "0.63.5", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", diff --git a/app/src/agentworld/iso/GameWorld.ts b/app/src/agentworld/iso/GameWorld.ts index 5c5f6f773..6adf28b0f 100644 --- a/app/src/agentworld/iso/GameWorld.ts +++ b/app/src/agentworld/iso/GameWorld.ts @@ -2,9 +2,10 @@ * The top-level world controller. * * `GameWorld` owns the PixiJS application (WebGPU-preferred), the active room, - * every agent, and the render loop. It exposes the authoritative entry point - * {@link GameWorld.updateAgentState} for external/AI control, plus click-to-move - * for human debugging. The 600x600 native scene lives inside a single "viewport" + * every agent, and the render loop. It exposes the reconciliation seam + * {@link GameWorld.updateAgentState}, reserved for future external/AI control + * (no callers yet), plus click-to-move for human debugging. The 600x600 native + * scene lives inside a single "viewport" * container that is scaled to fill its parent, so the world stays crisp pixel * art at any size. */ @@ -574,6 +575,14 @@ export class GameWorld { * agent should be and what it should do; the world spawns it if new and * slides it toward that state. Unknown targets are ignored rather than * teleporting the agent into a wall. + * + * TODO(#4922): this is the intended relay-presence seam, but nothing in the + * app currently drives it from the network — the tinyplace stream layer only + * supports `inbox` / `conversation` kinds (no presence/world-state stream), + * so the World is a local ambient simulation today. Wire this to a relay + * presence feed to make other users'/agents' positions reflect tiny.place in + * real time. Until then this method has no callers at all — it is a reserved + * seam, not a live local path. */ public updateAgentState(agentId: string, state: AgentState): void { const room = this.room; @@ -706,7 +715,14 @@ export class GameWorld { this.notifyChange(); } - /** Populate the room with demo agents — some seated at stations. */ + /** + * Populate the room with demo agents — some seated at stations. + * + * TODO(#4922): these are locally-seeded NPCs (random names/tints via + * {@link GameWorld.pickName} / {@link GameWorld.pickTint}), not real + * tiny.place participants. When relay-backed presence lands, seed from the + * live directory/presence feed via {@link GameWorld.updateAgentState} instead. + */ public spawnAgents(count: number): void { const room = this.room; if (!room) { diff --git a/app/src/agentworld/pages/IdentitiesSection.test.tsx b/app/src/agentworld/pages/IdentitiesSection.test.tsx index 9f8d2b497..59f50e6d0 100644 --- a/app/src/agentworld/pages/IdentitiesSection.test.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.test.tsx @@ -17,6 +17,7 @@ import { type DirectoryIdentityListingsResponse, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { openUrl } from '../../utils/openUrl'; import { apiClient } from '../AgentWorldShell'; import IdentitiesSection, { IDENTITY_PRICING_TIERS } from './IdentitiesSection'; @@ -39,6 +40,11 @@ vi.mock('../AgentWorldShell', () => ({ }, })); +// External-link opener — assert the seller CTA hands off to the OS browser +// without actually invoking Tauri. +// openUrl returns a Promise (the CTA chains .then/.catch for diagnostics). +vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn(() => Promise.resolve()) })); + // Default happy-path resolutions so async hooks settle without unhandled // rejections. Individual tests override per-case. beforeEach(() => { @@ -1282,3 +1288,28 @@ describe('IDENTITY_PRICING_TIERS', () => { expect(screen.queryByText('$10/yr')).not.toBeInTheDocument(); }); }); + +// Unit coverage only, by design: the affordance is a static info note whose CTA +// hands off to the OS browser via the mocked `openUrl`. There is one real +// cross-process effect (that the OS browser actually opens) but it isn't worth a +// desktop WDIO E2E — these unit tests cover the note + the openUrl call. E2E is +// intentionally skipped for this change (#4920). +describe('Trading tab — seller web-only note', () => { + test('renders the seller pointer note on the Trading tab', async () => { + render(); + await gotoTab('Trading'); + const note = await screen.findByTestId('sell-on-web'); + expect(note).toHaveTextContent(/listing a handle for sale and accepting offers/i); + expect(screen.getByTestId('sell-on-web-cta')).toHaveAttribute( + 'data-analytics-id', + 'identities.sellOnWeb' + ); + }); + + test('CTA opens the tiny.place identities page via openUrl', async () => { + render(); + await gotoTab('Trading'); + await userEvent.click(await screen.findByTestId('sell-on-web-cta')); + expect(vi.mocked(openUrl)).toHaveBeenCalledWith('https://tiny.place/identities'); + }); +}); diff --git a/app/src/agentworld/pages/IdentitiesSection.tsx b/app/src/agentworld/pages/IdentitiesSection.tsx index 47fff1ddb..d173f8a96 100644 --- a/app/src/agentworld/pages/IdentitiesSection.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.tsx @@ -12,7 +12,14 @@ * Offer commitments (signed authorizations — funds move only on acceptance). * Money only moves after the user confirms. The read-only data views (Registry * listing, floor prices, recent sales) are fully functional. + * + * Seller-side *writes* (list a handle for sale, accept / reject an offer/bid) + * are intentionally NOT in-app — they are web-only on tiny.place (the backend + * exposes no seller write routes). Seller-side *reads* do exist (offers on your + * handles, via marketplace_list_offers). The Trading tab links sellers to the + * web app for the write actions it cannot perform (#4920). */ +import debugFactory from 'debug'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import ChipTabs from '../../components/layout/ChipTabs'; @@ -31,12 +38,21 @@ import { type RegistrationChallenge, type RegistryWalletBalance, } from '../../lib/agentworld/invokeApiClient'; +import { openUrl } from '../../utils/openUrl'; import { apiClient } from '../AgentWorldShell'; import { decimalsForAsset, formatAssetAmount } from '../assets'; import CommitFlow from '../components/CommitFlow'; import X402ConfirmDialog from '../components/X402ConfirmDialog'; import { explorerTxUrl as buyExplorerTxUrl, useX402Buy } from '../hooks/useX402Buy'; +const debug = debugFactory('agentworld:identities'); + +// Seller-side identity *writes* (list a handle for sale, accept/reject an offer) +// are web-only — the tiny.place backend exposes no seller write routes (see +// #4920). We point sellers at the web app for those. Hardcoded prod URL, +// matching the `FUND_PAGE_URL` precedent in X402ConfirmDialog.tsx. +const SELL_ON_WEB_URL = 'https://tiny.place/identities'; + // ── Types ───────────────────────────────────────────────────────────────────── type Tab = 'register' | 'registry' | 'trading'; @@ -945,6 +961,35 @@ function TradingTab() { )} + {/* Seller-side *writes* (list-for-sale / accept-reject offer) are web-only + (#4920): the backend exposes no seller write routes. Placed directly + under the listings — where someone who came to sell looks — rather than + below Recent Sales. Viewing offers on your handles is a separate read + that does exist (marketplace_list_offers). */} +
+

+ Listing a handle for sale and accepting offers happens on tiny.place. +

+ +
+ {/* Recent sales */}

diff --git a/app/src/agentworld/pages/WorldSection.test.tsx b/app/src/agentworld/pages/WorldSection.test.tsx index 63d236be5..2886ce94a 100644 --- a/app/src/agentworld/pages/WorldSection.test.tsx +++ b/app/src/agentworld/pages/WorldSection.test.tsx @@ -81,6 +81,22 @@ describe('WorldSection renderer boot', () => { expect(screen.queryByText(/booting renderer/i)).not.toBeInTheDocument(); }); + test('renders an accessible "offline preview" pill so users know agents are a local sim (#4922)', () => { + initImpl = () => new Promise(() => {}); // renderer state is irrelevant here + render(); + // The badge is a focusable button (operable under the card's + // pointer-events-none), not a decorative span. + const badge = screen.getByRole('button', { name: /offline preview/i }); + expect(badge).toBeInTheDocument(); + // Its explanatory tooltip is wired via aria-describedby → role="tooltip", + // rather than a bare `title` that pointer-events-none would swallow. + const tooltipId = badge.getAttribute('aria-describedby'); + expect(tooltipId).toBeTruthy(); + const tooltip = document.getElementById(tooltipId as string); + expect(tooltip).toHaveAttribute('role', 'tooltip'); + expect(tooltip).toHaveTextContent(/local simulation/i); + }); + test('Retry re-invokes init and recovers to ready on success', async () => { const user = userEvent.setup(); // First attempt fails, second succeeds. diff --git a/app/src/agentworld/pages/WorldSection.tsx b/app/src/agentworld/pages/WorldSection.tsx index c5a8e555a..811bdc1ef 100644 --- a/app/src/agentworld/pages/WorldSection.tsx +++ b/app/src/agentworld/pages/WorldSection.tsx @@ -153,9 +153,38 @@ export default function WorldSection() {

{t( 'agentWorld.world.description', - 'Join tiny.place so your agent can coordinate with other agents — find and post jobs, trade, message, and team up on bounties.' + 'Join tiny.place so your agent can coordinate with other agents on the network — find and post jobs, trade, message, and team up on bounties.' )}

+ {/* The World is a local ambient simulation today: NPCs are seeded + client-side and there is no relay-backed presence / world-state sync + (see GameWorld.updateAgentState + GameWorld.spawnAgents, #4922). This + pill sets expectations until live presence lands. + + The parent card is `pointer-events-none`, so the badge opts back in + with `pointer-events-auto` to stay hoverable/focusable. It uses the + project's self-contained wrapping tooltip pattern (see + SuperContextToggle) rather than the shared , which is + single-line nowrap and can't fit this sentence, and rather than a + bare `title` (unreachable under `pointer-events-none`). */} + + + + {t( + 'agentWorld.world.offlineBadgeTitle', + 'Agents shown here are a local simulation. Live presence and world sync are coming soon.' + )} + +