From 51c09234958eb259dcb2ed849c70e1ac604c1733 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Sun, 27 Sep 2026 10:30:49 -0500 Subject: [PATCH 01/28] fix(desktop): probe the Codex engine codex-acp will actually run Codex readiness and the cached auth badge ran `codex login status` with whatever `codex` was first on PATH. codex-acp never uses that binary: it spawns `CODEX_PATH` when set, else the @openai/codex it bundles. When the global CLI lags the Codex desktop app's config (e.g. an old CLI rejecting `model_reasoning_effort = "ultra"`), a working agent parked in setup mode with a false "config.toml is invalid" card. Both probes now go through `cli_probe::probe_command`: CODEX_PATH (agent env over the inherited app env; empty = unset, as in codex-acp), else `node /node_modules/@openai/codex/bin/codex.js` resolved the way Node resolves it from the adapter package (Windows .cmd/sh shim layout and unix bin symlink), else the PATH `codex`. ConfigInvalid classification is unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- .../discovery/auth_status_cache.rs | 18 +-- .../src-tauri/src/managed_agents/readiness.rs | 4 +- .../src/managed_agents/readiness/cli_login.rs | 41 +++++-- .../src/managed_agents/readiness/cli_probe.rs | 112 +++++++++++++++++- 4 files changed, 157 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs index cae0d7e2c94..4ff4453a9ec 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs @@ -8,6 +8,7 @@ //! forced discovery calls before re-probing). use std::collections::HashMap; +use std::path::Path; use std::sync::{Mutex, OnceLock}; use crate::managed_agents::AuthStatus; @@ -52,9 +53,11 @@ pub(crate) fn len() -> usize { /// default path spawns nothing — it reuses the last cached status, falling back /// to `Unknown` for a runtime never probed this session. pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force: bool) { - use crate::managed_agents::AcpAvailabilityStatus; + use crate::managed_agents::{readiness::cli_probe, AcpAvailabilityStatus}; if force { + // No agent env here, so only the app env's `CODEX_PATH` applies. + let codex_path = cli_probe::codex_path_env(None); let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials .iter() .enumerate() @@ -63,14 +66,15 @@ pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force: return None; } let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = super::resolve_command(probe_args[0])?; - let probe_args_owned: Vec = - probe_args.iter().map(|s| s.to_string()).collect(); + // Probe the CLI the runtime will actually run (for codex, the + // engine bundled with the resolved adapter). + let adapter_path = partial.entry.binary_path.as_deref().map(Path::new); + let (program, argv) = + cli_probe::probe_command(probe_args, adapter_path, codex_path.as_deref())?; let handle = std::thread::spawn(move || { - let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - super::probe_auth_status(&binary_path, &refs) + let refs: Vec<&str> = argv.iter().map(String::as_str).collect(); + super::probe_auth_status(&program, &refs) }); Some((idx, handle)) }) diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fc53ff312cc..8e7e077b7fa 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -454,7 +454,7 @@ fn collect_missing_requirements( "complete Claude Code authentication by running the Claude CLI", rt, ), - "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + "codex" => cli_login::codex_requirements(rt, &effective.env), _ => vec![], } } @@ -1013,7 +1013,7 @@ mod tests { // Use a not-installed runtime so the requirement is always emitted // regardless of whether codex is on the test machine's PATH. let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None); - let reqs = cli_login::requirements(&["codex", "login", "status"], "run `codex login`", &rt); + let reqs = cli_login::codex_requirements(&rt, &BTreeMap::new()); // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. for req in &reqs { if let Requirement::CliLogin { setup_copy, .. } = req { diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f2393..445fce204a2 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -1,20 +1,43 @@ +use std::collections::BTreeMap; use std::path::Path; use crate::managed_agents::{ - discovery::{ - classify_runtime, codex_adapter_availability, find_command, resolve_command, - KnownAcpRuntime, - }, + discovery::{classify_runtime, codex_adapter_availability, find_command, KnownAcpRuntime}, AcpAvailabilityStatus, }; use super::{cli_probe, Requirement}; -/// Requirements for CLI-login runtimes (claude, codex). +/// Codex requirements, probed with the engine this agent's codex-acp will run +/// (`CODEX_PATH` from `agent_env` or the app env, else the bundled engine). +pub(super) fn codex_requirements( + runtime: &KnownAcpRuntime, + agent_env: &BTreeMap, +) -> Vec { + let codex_path = cli_probe::codex_path_env(Some(agent_env)); + probe_requirements( + &["codex", "login", "status"], + "run `codex login`", + runtime, + codex_path.as_deref(), + ) +} + +/// Requirements for a CLI-login runtime probed with `probe_args` as given +/// (claude; codex goes through [`codex_requirements`]). pub(super) fn requirements( probe_args: &[&str], setup_copy: &str, runtime: &KnownAcpRuntime, +) -> Vec { + probe_requirements(probe_args, setup_copy, runtime, None) +} + +fn probe_requirements( + probe_args: &[&str], + setup_copy: &str, + runtime: &KnownAcpRuntime, + codex_path: Option<&str>, ) -> Vec { let adapter_result = runtime .commands @@ -39,15 +62,19 @@ pub(super) fn requirements( match availability { AcpAvailabilityStatus::Available => { - let Some(binary_path) = resolve_command(probe_args[0]) else { + let adapter_path = adapter_path.as_deref().map(Path::new); + let Some((program, argv)) = + cli_probe::probe_command(probe_args, adapter_path, codex_path) + else { return vec![missing_requirement( probe_args, setup_copy, AcpAvailabilityStatus::Available, )]; }; + let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); let augmented_path = cli_probe::augmented_path(); - match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + match cli_probe::login_probe(&program, &argv, augmented_path.as_deref()) { cli_probe::ProbeOutcome::LoggedIn => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a85..7fedcbb18ce 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -1,6 +1,7 @@ -use std::path::Path; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; -use crate::managed_agents::runtime::build_augmented_path; +use crate::managed_agents::{resolve_command, runtime::build_augmented_path}; /// Build the augmented PATH for CLI probes and other native child processes /// (auth commands, `buzz-acp models` discovery), including nvm's default @@ -49,6 +50,65 @@ pub(crate) enum ProbeOutcome { /// one term. const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown variant"]; +/// `CODEX_PATH` as a spawned codex-acp sees it: the agent's env over the app +/// env the spawn inherits. Empty counts as unset, as it does in codex-acp. +pub(crate) fn codex_path_env(agent_env: Option<&BTreeMap>) -> Option { + agent_env + .and_then(|env| env.get("CODEX_PATH").cloned()) + .or_else(|| std::env::var("CODEX_PATH").ok()) + .filter(|path| !path.is_empty()) +} + +/// The program and argv (`argv[0]` is only a label) a login probe runs. +/// +/// Codex is probed with the engine the agent will run, not whichever `codex` +/// is first on PATH: codex-acp spawns `CODEX_PATH` when set, else the +/// `@openai/codex` it bundles. An older global CLI can reject config values +/// the bundled engine accepts, which parks a working agent in setup mode. +pub(crate) fn probe_command( + probe_args: &[&str], + adapter_path: Option<&Path>, + codex_path: Option<&str>, +) -> Option<(PathBuf, Vec)> { + let owned = |args: &[&str]| args.iter().map(|arg| arg.to_string()).collect::>(); + if probe_args[0] == "codex" { + if let Some(path) = codex_path.and_then(resolve_command) { + return Some((path, owned(probe_args))); + } + if let Some(codex_js) = adapter_path.and_then(bundled_codex_js) { + let mut argv = vec!["node".to_string(), codex_js.to_string_lossy().into_owned()]; + argv.extend(owned(&probe_args[1..])); + // Bare `node`, resolved through the probe's augmented PATH exactly + // as the adapter's own shim resolves it. + return Some((PathBuf::from("node"), argv)); + } + } + Some((resolve_command(probe_args[0])?, owned(probe_args))) +} + +/// `@openai/codex/bin/codex.js` as Node resolves it from the codex-acp +/// package: nested under the package first, then hoisted into an ancestor. +fn bundled_codex_js(adapter_path: &Path) -> Option { + // npm's Windows `.cmd` (and sh) shims sit beside `node_modules`; a unix + // bin symlink resolves into the package's `dist/`. + let shim_package: PathBuf = ["node_modules", "@agentclientprotocol", "codex-acp"] + .iter() + .collect(); + let shim_package = adapter_path.parent()?.join(shim_package); + let start = if shim_package.is_dir() { + shim_package + } else { + adapter_path.canonicalize().ok()?.parent()?.to_path_buf() + }; + let codex_js: PathBuf = ["node_modules", "@openai", "codex", "bin", "codex.js"] + .iter() + .collect(); + start + .ancestors() + .map(|dir| dir.join(&codex_js)) + .find(|path| path.is_file()) +} + /// Run the probe at the resolved absolute path so the GUI-PATH gap is /// bypassed. Injects the same augmented PATH used for launched agents so /// script shims with `/usr/bin/env ` shebangs can find runtimes @@ -233,6 +293,54 @@ mod tests { ); } + /// The probe must run the Codex engine codex-acp bundles, not whichever + /// `codex` is first on PATH: an older global CLI rejects config values the + /// bundled engine accepts and parks a working agent in setup mode. + #[test] + fn codex_probe_runs_engine_bundled_with_adapter_not_path_codex() { + use std::path::PathBuf; + + let temp = tempfile::tempdir().expect("temp dir"); + let package: PathBuf = ["node_modules", "@agentclientprotocol", "codex-acp"] + .iter() + .collect(); + let package = temp.path().join(package); + let codex_bin: PathBuf = ["node_modules", "@openai", "codex", "bin"].iter().collect(); + std::fs::create_dir_all(package.join(&codex_bin)).expect("bundled codex dir"); + let codex_js = package.join(&codex_bin).join("codex.js"); + std::fs::write(&codex_js, "").expect("write codex.js"); + let probe = |adapter: &std::path::Path| { + super::probe_command(&["codex", "login", "status"], Some(adapter), None) + .expect("a codex probe command") + }; + let expected = |js: &std::path::Path| { + let js = js.to_string_lossy().into_owned(); + ( + PathBuf::from("node"), + vec!["node".to_string(), js, "login".into(), "status".into()], + ) + }; + + // npm's Windows `.cmd` / sh shim layout: the shim sits beside node_modules. + let shim = temp.path().join("codex-acp.cmd"); + std::fs::write(&shim, "").expect("write shim"); + assert_eq!(probe(&shim), expected(&codex_js)); + + // Unix npm-global layout: bin/codex-acp links into the package's dist/. + #[cfg(unix)] + { + let dist = package.join("dist"); + std::fs::create_dir_all(&dist).expect("dist dir"); + std::fs::write(dist.join("index.js"), "").expect("write index.js"); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(&bin).expect("bin dir"); + std::os::unix::fs::symlink(dist.join("index.js"), bin.join("codex-acp")) + .expect("symlink adapter"); + let codex_js = codex_js.canonicalize().expect("canonical codex.js"); + assert_eq!(probe(&bin.join("codex-acp")), expected(&codex_js)); + } + } + /// Verify that every string in CONFIG_PARSE_SIGNALS is lowercased so the /// case-insensitive match works correctly. #[test] From 40d70573dfb3dd0e7987826615f24b5bb13f26bd Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Sun, 27 Sep 2026 10:56:01 -0500 Subject: [PATCH 02/28] fix(desktop): treat a set CODEX_PATH as strict in the codex probe codex-acp spawns CODEX_PATH whenever it is set, so a CODEX_PATH that does not resolve must not fall back to the bundled or PATH engine (which would report Ready for an agent that then fails to spawn). Readiness now surfaces it as a missing binary naming `CODEX_PATH=` instead of asking for a `codex login`; the badge probe skips it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- .../src/managed_agents/readiness/cli_login.rs | 18 +++++++++++++----- .../src/managed_agents/readiness/cli_probe.rs | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 445fce204a2..1fbb5529d53 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -66,11 +66,19 @@ fn probe_requirements( let Some((program, argv)) = cli_probe::probe_command(probe_args, adapter_path, codex_path) else { - return vec![missing_requirement( - probe_args, - setup_copy, - AcpAvailabilityStatus::Available, - )]; + // With `codex_path` set, only a CODEX_PATH that does not + // resolve lands here: codex-acp would fail to spawn it, so + // name that setting rather than asking for a login. + return vec![match codex_path { + Some(path) => Requirement::MissingBinary { + command: format!("CODEX_PATH={path}"), + }, + None => missing_requirement( + probe_args, + setup_copy, + AcpAvailabilityStatus::Available, + ), + }]; }; let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); let augmented_path = cli_probe::augmented_path(); diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 7fedcbb18ce..89362594e72 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -65,6 +65,10 @@ pub(crate) fn codex_path_env(agent_env: Option<&BTreeMap>) -> Op /// is first on PATH: codex-acp spawns `CODEX_PATH` when set, else the /// `@openai/codex` it bundles. An older global CLI can reject config values /// the bundled engine accepts, which parks a working agent in setup mode. +/// +/// A set `CODEX_PATH` is strict, as in codex-acp: when it does not resolve +/// this returns `None` rather than falling back to an engine the agent +/// would never run. pub(crate) fn probe_command( probe_args: &[&str], adapter_path: Option<&Path>, @@ -72,8 +76,8 @@ pub(crate) fn probe_command( ) -> Option<(PathBuf, Vec)> { let owned = |args: &[&str]| args.iter().map(|arg| arg.to_string()).collect::>(); if probe_args[0] == "codex" { - if let Some(path) = codex_path.and_then(resolve_command) { - return Some((path, owned(probe_args))); + if let Some(path) = codex_path { + return Some((resolve_command(path)?, owned(probe_args))); } if let Some(codex_js) = adapter_path.and_then(bundled_codex_js) { let mut argv = vec!["node".to_string(), codex_js.to_string_lossy().into_owned()]; @@ -326,6 +330,14 @@ mod tests { std::fs::write(&shim, "").expect("write shim"); assert_eq!(probe(&shim), expected(&codex_js)); + // A set CODEX_PATH is the engine codex-acp runs even when it is broken: + // no silent fallback to the bundled engine. + let missing = temp.path().join("missing-codex").display().to_string(); + assert_eq!( + super::probe_command(&["codex", "login", "status"], Some(&shim), Some(&missing)), + None + ); + // Unix npm-global layout: bin/codex-acp links into the package's dist/. #[cfg(unix)] { From bc9b82fe45b7edd1ab0f1cef382e2ef2c1c5b159 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Sun, 27 Sep 2026 11:38:26 -0500 Subject: [PATCH 03/28] fix(acp): parse the desktop's missing_binary setup requirement Port of the buzz-acp half of upstream block/buzz#7594 (Salman Mohammed). The desktop readiness engine can emit `Requirement::MissingBinary`, and this change makes it do so for a CODEX_PATH that does not resolve. The fork's setup-mode parser did not know the variant, so the setup listener exited on a malformed BUZZ_ACP_SETUP_PAYLOAD and the agent crash-looped instead of posting a nudge (upstream issues #4628, #7613). Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- crates/buzz-acp/src/setup_mode.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 70b5a8dcb28..c924e26f0fd 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -116,6 +116,8 @@ pub(crate) enum RequirementPayload { }, /// Git for Windows is missing; open Agent runtimes for the installation guide. GitBash, + /// A custom harness command could not be resolved in the current PATH. + MissingBinary { command: String }, } impl RequirementPayload { @@ -190,6 +192,9 @@ impl RequirementPayload { RequirementPayload::GitBash => { "install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string() } + RequirementPayload::MissingBinary { command } => { + format!("install `{command}` or add it to PATH") + } } } } @@ -262,6 +267,10 @@ impl SetupPayload { .requirements .iter() .all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); + let all_missing_binary = self + .requirements + .iter() + .all(|r| matches!(r, RequirementPayload::MissingBinary { .. })); let any_external = self .requirements .iter() @@ -269,6 +278,8 @@ impl SetupPayload { let footer = if has_doctor_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() + } else if all_missing_binary { + "Install the missing binary or update PATH, then restart Buzz.".to_string() } else if all_external { // All requirements are external config files — Edit Agent cannot // help. Don't send the user there. @@ -739,6 +750,23 @@ mod tests { )); } + #[test] + fn setup_payload_deserializes_missing_binary_requirement() { + let payload = SetupPayload::from_raw_env_value(Some( + r#"{"agent_name":"Carol","agent_pubkey":"test","requirements":[{"surface":"missing_binary","command":"buzz-pi-acp"}]}"#.to_string(), + )) + .unwrap() + .expect("missing_binary payload must parse"); + assert!(matches!( + payload.requirements.as_slice(), + [RequirementPayload::MissingBinary { command }] if command == "buzz-pi-acp" + )); + let body = payload.nudge_body(); + assert!(body.contains("install `buzz-pi-acp` or add it to PATH")); + assert!(body.contains("restart Buzz")); + assert!(!body.contains("Open Edit Agent")); + } + #[tokio::test] async fn authorized_workflow_nudge_mentions_effective_owner_not_relay_signer() { let agent_keys = nostr::Keys::generate(); From 1bc6fd06800fcc871993636ee0ebecb58b39bb80 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Sun, 27 Sep 2026 11:44:59 -0500 Subject: [PATCH 04/28] fix(desktop): stop requiring a global codex CLI for Codex agents The codex runtime declared `underlying_cli: Some("codex")`, so an installed codex-acp with no `codex` on PATH classified as CliMissing: readiness parked the agent ("codex CLI is missing"), Agent runtimes hid the Connect button (shown only when Available) and offered to install a CLI the agent never runs. Every supported codex-acp (>= the 1.1.7 floor) depends on @openai/codex and runs that bundled engine unless CODEX_PATH is set; the readiness and badge probes already use it. Drop the CLI requirement so an installed adapter is Available, and lead the logged-out copy with the in-app Connect flow, since `codex login` needs the optional CLI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- .../src/managed_agents/discovery/catalog.rs | 6 +++++- .../managed_agents/discovery/runtime_metadata.rs | 16 ++++++++++++++++ .../src/managed_agents/readiness/cli_login.rs | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs index fecf792f214..8f48b396c14 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -90,7 +90,11 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, - underlying_cli: Some("codex"), + // No global `codex` needed: every supported codex-acp (>= MIN_CODEX_ACP_VERSION) + // depends on @openai/codex and runs that bundled engine unless CODEX_PATH is set. + // Requiring a PATH `codex` marked working installs CliMissing and hid Connect. + // The cli_install_* fields below go unused while this is None. + underlying_cli: None, cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index b68bc84c23f..6b45183e869 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -218,4 +218,20 @@ mod tests { assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); } + + /// codex-acp runs the Codex engine it bundles (or `CODEX_PATH`), so an + /// installed adapter with no `codex` on PATH is Available, not CliMissing. + #[test] + fn codex_adapter_without_global_cli_is_available() { + let codex = known_acp_runtime_exact("codex").unwrap(); + let (availability, ..) = super::super::classify_runtime( + Some(("codex-acp", std::path::PathBuf::from("codex-acp"))), + codex.underlying_cli, + false, + ); + assert_eq!( + availability, + crate::managed_agents::AcpAvailabilityStatus::Available + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 1fbb5529d53..b35cd2e6ee7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -17,7 +17,8 @@ pub(super) fn codex_requirements( let codex_path = cli_probe::codex_path_env(Some(agent_env)); probe_requirements( &["codex", "login", "status"], - "run `codex login`", + // A global Codex CLI is optional, so lead with the in-app connect flow. + "connect your Codex account in Agent runtimes, or run `codex login` if the Codex CLI is installed", runtime, codex_path.as_deref(), ) From 659f193ac25dbe3ac1150149b7c604f7ca536900 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 28 Sep 2026 08:46:45 -0500 Subject: [PATCH 05/28] feat(release): say plainly which file to download, one version per page The release page now opens with a table: Windows PC, Mac with an Apple chip, Mac with an Intel chip, each linking its exact file, plus how to tell which Mac you have and step-by-step install for both. The text lives in scripts/dreamforge-release-notes.md; the publish job fills in the version and links, sets the title to "Dreamforge ", rewrites the notes on every publish, and after latest.json is uploaded removes the previous version's files so the page only ever shows one set. Co-Authored-By: Claude Opus 5.5 --- .../workflows/dreamforge-desktop-release.yml | 24 ++++++++++------- scripts/dreamforge-release-notes.md | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 scripts/dreamforge-release-notes.md diff --git a/.github/workflows/dreamforge-desktop-release.yml b/.github/workflows/dreamforge-desktop-release.yml index 36d43dc9a80..d09ba97c851 100644 --- a/.github/workflows/dreamforge-desktop-release.yml +++ b/.github/workflows/dreamforge-desktop-release.yml @@ -286,17 +286,21 @@ jobs: cat latest.json - name: Upload to the rolling release - env: - NOTES: | - Dreamforge desktop installers. Take the newest version number. - - **Windows:** download `Dreamforge__x64-setup.exe` and run it. If SmartScreen says "Windows protected your PC", click **More info**, then **Run anyway**. - - **Mac:** download the DMG for your chip (`aarch64` for Apple Silicon, `x64` for Intel), open it and drag Dreamforge to Applications. The first launch is blocked because the app is not notarized: open **System Settings**, **Privacy & Security**, and click **Open Anyway**. - - Installed apps update themselves from `latest.json` here. run: | - gh release view "$TAG" > /dev/null 2>&1 || gh release create "$TAG" --target "$GITHUB_SHA" --title "Dreamforge desktop" --notes "$NOTES" + BASE="https://github.com/$GH_REPO/releases/download/$TAG" + # VERSION is plain x.y.z here: "Write latest.json" refused anything else. + sed -e "s/{VERSION}/$VERSION/g" -e "s#{BASE}#$BASE#g" scripts/dreamforge-release-notes.md > notes.md + ! grep -q '[{}]' notes.md || { echo "::error::unfilled placeholder in the release notes"; exit 1; } + gh release view "$TAG" > /dev/null 2>&1 || gh release create "$TAG" --target "$GITHUB_SHA" --title "Dreamforge $VERSION" --notes-file notes.md gh release upload "$TAG" dist/*.dmg dist/*.app.tar.gz dist/*.exe dist/*.sig --clobber # latest.json last, so apps only see the new version once every file it names is uploaded. gh release upload "$TAG" latest.json --clobber + gh release edit "$TAG" --title "Dreamforge $VERSION" --notes-file notes.md + # Remove the previous version's files: latest.json no longer names them, + # and a single set keeps "which file is mine" obvious on the page. + gh release view "$TAG" --json assets --jq '.assets[].name' | while read -r name; do + case "$name" in + latest.json|*"_${VERSION}_"*) ;; + *) gh release delete-asset "$TAG" "$name" --yes ;; + esac + done diff --git a/scripts/dreamforge-release-notes.md b/scripts/dreamforge-release-notes.md new file mode 100644 index 00000000000..7eee593df17 --- /dev/null +++ b/scripts/dreamforge-release-notes.md @@ -0,0 +1,27 @@ +## Download Dreamforge {VERSION} + +Pick the one line that matches your computer and click its file. You only download from this page once: after that, Dreamforge updates itself. + +| Your computer | Click this file | +|---|---| +| **Windows PC** | [Dreamforge_{VERSION}_x64-setup.exe]({BASE}/Dreamforge_{VERSION}_x64-setup.exe) | +| **Mac with an Apple chip** (M1, M2, M3, M4 or newer; most Macs from late 2020 on) | [Dreamforge_{VERSION}_aarch64.dmg]({BASE}/Dreamforge_{VERSION}_aarch64.dmg) | +| **Mac with an Intel chip** (older Macs) | [Dreamforge_{VERSION}_x64.dmg]({BASE}/Dreamforge_{VERSION}_x64.dmg) | + +**Not sure which Mac you have?** Click the Apple menu in the top-left corner, then **About This Mac**. If it says **Chip: Apple M1** (or M2, M3, M4), take the Apple chip file. If it says **Processor: Intel**, take the Intel file. + +### Install on Windows + +1. Open the downloaded file. +2. If Windows says "Windows protected your PC", click **More info**, then **Run anyway**. This appears because the installer isn't signed with a paid Microsoft certificate. +3. Follow the installer, then open Dreamforge from the Start menu. + +### Install on a Mac + +1. Open the downloaded file and drag **Dreamforge** into **Applications**. +2. Open Dreamforge from Applications. The first time, macOS blocks it because it isn't notarized by Apple. +3. Open **System Settings**, then **Privacy & Security**, scroll down, and click **Open Anyway** next to Dreamforge. Confirm once more, and it opens normally from then on. + +### You can ignore the other files + +The files ending in `.app.tar.gz` and `.sig`, and `latest.json`, are what Dreamforge downloads by itself to update. The "Source code" links are added by GitHub. You don't need any of them. From 34d7dd0b05928ddd88a4e1d9cda722201e32544a Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 28 Sep 2026 08:51:54 -0500 Subject: [PATCH 06/28] fix(release): review, safer publish order and fuller install steps Publish: the notes edit now runs before latest.json goes live, and the old-file cleanup after it only warns (a failure there used to turn a shipped release red, and a rerun is refused as not newer). The asset list is captured without a pipe, since the publish job's bash has no pipefail. The pre-publish check now also requires both DMGs the page links. Page: Mac steps say to click Done, not Move to Trash, that Open Anyway lasts about an hour, and that it asks for the login password; chip guidance covers A-series Macs and matches About This Mac's Chip and Processor lines; Windows steps cover Edge's isn't-commonly-downloaded prompt and Smart App Control; the certificate wording is corrected; and updates are described as the Ready to update! card they are. Co-Authored-By: Claude Opus 5.5 --- .../workflows/dreamforge-desktop-release.yml | 11 +++++++---- scripts/dreamforge-release-notes.md | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dreamforge-desktop-release.yml b/.github/workflows/dreamforge-desktop-release.yml index d09ba97c851..72c13f13e2c 100644 --- a/.github/workflows/dreamforge-desktop-release.yml +++ b/.github/workflows/dreamforge-desktop-release.yml @@ -275,7 +275,7 @@ jobs: BASE="https://github.com/$GH_REPO/releases/download/$TAG" MAC="Dreamforge_${VERSION}" EXE="Dreamforge_${VERSION}_x64-setup.exe" - for f in "${MAC}_aarch64.app.tar.gz" "${MAC}_x64.app.tar.gz" "$EXE"; do + for f in "${MAC}_aarch64.app.tar.gz" "${MAC}_x64.app.tar.gz" "${MAC}_aarch64.dmg" "${MAC}_x64.dmg" "$EXE"; do [ -s "dist/$f" ] || { echo "::error::missing dist/$f"; exit 1; } done # --pubkey refuses signatures from any key but the one installed apps trust. @@ -293,14 +293,17 @@ jobs: ! grep -q '[{}]' notes.md || { echo "::error::unfilled placeholder in the release notes"; exit 1; } gh release view "$TAG" > /dev/null 2>&1 || gh release create "$TAG" --target "$GITHUB_SHA" --title "Dreamforge $VERSION" --notes-file notes.md gh release upload "$TAG" dist/*.dmg dist/*.app.tar.gz dist/*.exe dist/*.sig --clobber + # The notes only link files uploaded above, so the page may run ahead of the feed. + gh release edit "$TAG" --title "Dreamforge $VERSION" --notes-file notes.md # latest.json last, so apps only see the new version once every file it names is uploaded. gh release upload "$TAG" latest.json --clobber - gh release edit "$TAG" --title "Dreamforge $VERSION" --notes-file notes.md + # Live from here, and a rerun would be refused as "not newer": warn, never fail. # Remove the previous version's files: latest.json no longer names them, # and a single set keeps "which file is mine" obvious on the page. - gh release view "$TAG" --json assets --jq '.assets[].name' | while read -r name; do + names=$(gh release view "$TAG" --json assets --jq '.assets[].name') || echo "::warning::could not list release assets; remove old files by hand" + for name in $names; do case "$name" in latest.json|*"_${VERSION}_"*) ;; - *) gh release delete-asset "$TAG" "$name" --yes ;; + *) gh release delete-asset "$TAG" "$name" --yes || echo "::warning::could not delete $name; remove it by hand" ;; esac done diff --git a/scripts/dreamforge-release-notes.md b/scripts/dreamforge-release-notes.md index 7eee593df17..b32b567219a 100644 --- a/scripts/dreamforge-release-notes.md +++ b/scripts/dreamforge-release-notes.md @@ -1,26 +1,29 @@ ## Download Dreamforge {VERSION} -Pick the one line that matches your computer and click its file. You only download from this page once: after that, Dreamforge updates itself. +Pick the one line that matches your computer and click its file. You only download from this page once: after that, Dreamforge fetches new versions itself, and when **Ready to update!** appears in its sidebar, click it. | Your computer | Click this file | |---|---| | **Windows PC** | [Dreamforge_{VERSION}_x64-setup.exe]({BASE}/Dreamforge_{VERSION}_x64-setup.exe) | -| **Mac with an Apple chip** (M1, M2, M3, M4 or newer; most Macs from late 2020 on) | [Dreamforge_{VERSION}_aarch64.dmg]({BASE}/Dreamforge_{VERSION}_aarch64.dmg) | +| **Mac with an Apple chip** (M1, M2, M3, M4, M5, A18 Pro or newer; most Macs from late 2020 on) | [Dreamforge_{VERSION}_aarch64.dmg]({BASE}/Dreamforge_{VERSION}_aarch64.dmg) | | **Mac with an Intel chip** (older Macs) | [Dreamforge_{VERSION}_x64.dmg]({BASE}/Dreamforge_{VERSION}_x64.dmg) | -**Not sure which Mac you have?** Click the Apple menu in the top-left corner, then **About This Mac**. If it says **Chip: Apple M1** (or M2, M3, M4), take the Apple chip file. If it says **Processor: Intel**, take the Intel file. +**Not sure which Mac you have?** Click the Apple menu in the top-left corner, then **About This Mac**. If there is a line called **Chip** (Apple M1, M4, A18 Pro or any other Apple chip), take the Apple chip file. If there is a line called **Processor** that mentions **Intel**, take the Intel file. ### Install on Windows -1. Open the downloaded file. -2. If Windows says "Windows protected your PC", click **More info**, then **Run anyway**. This appears because the installer isn't signed with a paid Microsoft certificate. -3. Follow the installer, then open Dreamforge from the Start menu. +1. If your browser warns that the file "isn't commonly downloaded", keep it. In Edge, click **...** next to the download, then **Keep**, then **Show more**, then **Keep anyway**. +2. Open the downloaded file. +3. If Windows says "Windows protected your PC", click **More info**, then **Run anyway**. This appears because Dreamforge is new and its installer isn't signed with a paid code-signing certificate. +4. Follow the installer, then open Dreamforge from the Start menu. + +If Windows instead says **Smart App Control blocked an app**, there is no Run anyway button: Windows won't allow unsigned apps while Smart App Control is on. Ask whoever sent you this link for help before changing that setting. ### Install on a Mac 1. Open the downloaded file and drag **Dreamforge** into **Applications**. -2. Open Dreamforge from Applications. The first time, macOS blocks it because it isn't notarized by Apple. -3. Open **System Settings**, then **Privacy & Security**, scroll down, and click **Open Anyway** next to Dreamforge. Confirm once more, and it opens normally from then on. +2. Open Dreamforge from Applications. The first time, macOS says "Dreamforge" Not Opened, because it isn't notarized by Apple. Click **Done**, not Move to Trash. +3. Within the hour, open **System Settings**, then **Privacy & Security**, and scroll down to **Security**. Next to "Dreamforge" was blocked, click **Open Anyway**. Click **Open Anyway** again and enter your Mac login password (or use Touch ID). If the button isn't there, do step 2 again. From then on, Dreamforge opens normally. ### You can ignore the other files From 80f2adc7566ecd93c094ebeb43a9b9888983d84c Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 07:41:38 -0500 Subject: [PATCH 07/28] feat(bench): bench_cards and bench_answer commands for desktop cards bench_cards returns a read-only camelCase snapshot of state.json through the service's own loader: enabled (false, and nothing else, without the opt-in folder), channel, writer and owner pubkeys, lastError, lastTick and per item the drop fields, the live card id, the answer and what the card records. No key material. bench_answer(itemId, option) answers a pending item on its live card as the owner: it reads the owner's kind 7s on the card through the poll's own /query (Bench relay, writer NIP-98, lazy owner tag), plans with the pure plan_answer, then posts the keycap first and the kind 5 removals after through post_as_owner, so a failed delete never loses the new answer. The running tick records it as it would a phone tap; the command writes no state. item::reaction_target is factored out of pick_answer so both read a reaction's card the same way. Both commands are registered in lib.rs; this app has no AppManifest command list, so no capability entry is needed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- desktop/src-tauri/src/bench/commands.rs | 239 +++++++++++++++ desktop/src-tauri/src/bench/item.rs | 26 +- desktop/src-tauri/src/bench/mod.rs | 1 + desktop/src-tauri/src/bench/tests.rs | 1 + desktop/src-tauri/src/bench/tests/answers.rs | 10 +- .../src/bench/tests/desktop_cards.rs | 273 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + 7 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 desktop/src-tauri/src/bench/commands.rs create mode 100644 desktop/src-tauri/src/bench/tests/desktop_cards.rs diff --git a/desktop/src-tauri/src/bench/commands.rs b/desktop/src-tauri/src/bench/commands.rs new file mode 100644 index 00000000000..4cdf6d156c7 --- /dev/null +++ b/desktop/src-tauri/src/bench/commands.rs @@ -0,0 +1,239 @@ +//! Desktop cards: `bench_cards`, a read-only view of state.json for the +//! Dreamforge Bench screen, and `bench_answer`, which answers a card with the +//! owner's own keycap reaction so the running tick records it like a phone +//! tap. Neither writes state.json; neither returns or logs key material. + +use std::path::Path; + +use serde::Serialize; + +use super::item::{self, ItemKind, Link, Severity, StatusState, Validity, OPTION_EMOJI}; +use super::{live_card, load_state, post_as_owner, query, root_dir, BenchState, Ctx}; +use crate::app_state::AppState; +use crate::events; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BenchCards { + enabled: bool, + /// Absent while the opt-in folder is missing. + #[serde(flatten)] + snapshot: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Snapshot { + channel_id: String, + writer_pubkey: String, + owner_pubkey: Option, + last_error: Option, + last_tick: String, + items: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Card { + id: String, + writer: String, + kind: ItemKind, + title: String, + body: String, + area: String, + severity: Option, + options: Vec, + state: Option, + decided_by: Option, + deadline: Option, + links: Vec, + order: i64, + validity: Validity, + /// The live card only: one showing the item's current Drop. + card_event_id: Option, + answer: Option, + recorded: Option, +} + +/// 0-based, like `options` and `bench_answer`'s `option`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CardAnswer { + option: usize, + answered_at: u64, +} + +/// The snapshot under `root`, through the service's own state loader. +pub(crate) fn cards(root: &Path) -> BenchCards { + if !root.is_dir() { + return BenchCards { + enabled: false, + snapshot: None, + }; + } + let (s, _) = load_state(root); + let items = s + .items + .values() + .map(|i| Card { + id: i.id.clone(), + writer: i.writer.clone(), + kind: i.kind, + title: i.title.clone(), + body: i.body.clone(), + area: i.area.clone(), + severity: i.severity, + options: i.options.clone(), + state: i.state, + decided_by: i.decided_by.clone(), + deadline: i.deadline.clone(), + links: i.links.clone(), + order: i.order, + validity: i.validity, + card_event_id: live_card(i).map(|c| c.event_id.clone()), + answer: i.answer.as_ref().map(|a| CardAnswer { + option: a.option, + answered_at: a.answered_at, + }), + recorded: i.recorded, + }) + .collect(); + BenchCards { + enabled: true, + snapshot: Some(Snapshot { + channel_id: s.channel_id, + writer_pubkey: s.writer_pubkey, + owner_pubkey: Some(s.owner_pubkey).filter(|o| !o.is_empty()), + last_error: s.last_error, + last_tick: s.last_tick, + items, + }), + } +} + +/// The Bench as state.json last recorded it; read-only. +#[tauri::command] +pub(crate) async fn bench_cards() -> Result { + Ok(cards(&root_dir()?)) +} + +/// What to publish so the owner's keycaps on one card show exactly `chosen` +/// (None: none at all). +#[derive(Debug, Default, PartialEq)] +pub(crate) struct AnswerPlan { + pub add: Option<&'static str>, + /// Reaction event ids. + pub delete: Vec, +} + +/// `existing` is the owner's (event id, content) reactions on the card. Only +/// the card's own keycaps (the set pick_answer reads) are touched; any other +/// content is left alone. An out-of-range choice plans nothing. +pub(crate) fn plan_answer( + existing: &[(String, String)], + chosen: Option, + n_options: usize, +) -> AnswerPlan { + let keycaps = &OPTION_EMOJI[..n_options.min(OPTION_EMOJI.len())]; + let want = match chosen { + Some(i) if i >= keycaps.len() => return AnswerPlan::default(), + c => c.map(|i| keycaps[i]), + }; + AnswerPlan { + add: want.filter(|w| !existing.iter().any(|(_, c)| c == w)), + delete: existing + .iter() + .filter(|(_, c)| keycaps.contains(&c.as_str()) && want != Some(c.as_str())) + .map(|(id, _)| id.clone()) + .collect(), + } +} + +/// bench_answer's relay half. Reads the owner's reactions on the live card +/// with the poll's own /query, then publishes the add before the deletes, so +/// a failed delete never loses the new answer (latest wins in pick_answer). +pub(crate) async fn answer_card( + ctx: &Ctx<'_>, + s: &BenchState, + item_id: &str, + option: Option, + now: u64, +) -> Result<(), String> { + let item = s + .items + .get(item_id) + .ok_or_else(|| format!("no Bench item {item_id:?}"))?; + if item.kind != ItemKind::Pending { + return Err(format!("{item_id} is not a pending item")); + } + let card = live_card(item) + .ok_or_else(|| format!("{item_id} has no live card yet"))? + .event_id + .clone(); + let n = item.options.len().min(OPTION_EMOJI.len()); + let chosen = option.map(usize::from); + if let Some(i) = chosen.filter(|i| *i >= n) { + return Err(format!( + "option {i} out of range: {item_id} has {n} options" + )); + } + let owner = ctx.owner.public_key(); + if s.owner_pubkey != owner.to_hex() { + return Err("owner identity changed; refusing to answer".to_string()); + } + let filter = serde_json::json!({ + "kinds": [7], "authors": [s.owner_pubkey], "#h": [s.channel_id], "#e": [card], + "limit": super::RECOVER_LIMIT, + }); + let events = query(ctx, s, filter, now).await?; + // A cut page could hide a keycap that would outrank the new one. + if events.len() as u32 >= super::RECOVER_LIMIT { + return Err("too many reactions on this card to plan an answer".to_string()); + } + let existing: Vec<(String, String)> = events + .iter() + .filter(|e| { + e.kind.as_u16() == 7 + && e.pubkey == owner + && item::reaction_target(e) == Some(card.as_str()) + }) + .map(|e| (e.id.to_hex(), e.content.clone())) + .collect(); + let plan = plan_answer(&existing, chosen, n); + let target = nostr::EventId::from_hex(&card).map_err(|e| e.to_string())?; + if let Some(emoji) = plan.add { + post_as_owner(ctx, events::build_reaction(target, emoji)?).await?; + } + for id in plan.delete { + let id = nostr::EventId::from_hex(&id).map_err(|e| e.to_string())?; + post_as_owner(ctx, events::build_remove_reaction(id)?).await?; + } + Ok(()) +} + +/// `option`: Some(i) answers with option i (0-based), None clears the answer. +/// The Bench relay and keys, never the app's active community. +#[tauri::command] +pub(crate) async fn bench_answer( + item_id: String, + option: Option, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let env = std::env::var("BUZZ_BENCH_RELAY_URL").ok(); + let relay = + super::relay_ws_from(env.as_deref()).ok_or("the Bench is disabled in this build")?; + let root = root_dir()?; + if !root.is_dir() { + return Err("the Bench is not enabled".to_string()); + } + let (s, _) = load_state(&root); + let ctx = Ctx { + state: state.inner(), + base: crate::relay::relay_http_base_url(&relay), + owner: state.signing_keys()?, + writer: super::writer_keys()?, + root, + }; + let now = nostr::Timestamp::now().as_secs(); + answer_card(&ctx, &s, &item_id, option, now).await +} diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 01399fd6a09..bf449dfaf40 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -537,9 +537,21 @@ pub(crate) fn ping_owner(item: &Item) -> bool { && item.writer != MIGRATION_WRITER } +/// The event a reaction is aimed at: its last `e` tag, how the relay scopes +/// a reaction. +pub(crate) fn reaction_target(e: &nostr::Event) -> Option<&str> { + e.tags + .iter() + .filter_map(|t| match t.as_slice() { + [k, v, ..] if k == "e" => Some(v.as_str()), + _ => None, + }) + .last() +} + /// The answer on `item`'s live card: the NEWEST valid kind 7 by an approver -/// other than the writer, aimed at the card by its last `e` tag (how the -/// relay scopes a reaction) and echoing one seeded keycap byte-for-byte. +/// other than the writer, aimed at the card by its `reaction_target` and +/// echoing one seeded keycap byte-for-byte. /// Latest wins so a second tap changes the answer; a retraction is the /// reaction no longer coming back from the relay, which hands the answer to /// the next newest. @@ -555,18 +567,10 @@ pub(crate) fn pick_answer<'e>( .iter() .filter_map(|e| { let pk = e.pubkey.to_hex(); - let target = e - .tags - .iter() - .filter_map(|t| match t.as_slice() { - [k, v, ..] if k == "e" => Some(v.as_str()), - _ => None, - }) - .last(); let ok = e.kind.as_u16() == 7 && pk != writer_hex && approvers.contains(&pk) - && target == Some(card.event_id.as_str()) + && reaction_target(e) == Some(card.event_id.as_str()) && e.verify().is_ok(); let i = OPTION_EMOJI[..n].iter().position(|o| *o == e.content)?; ok.then_some((i, e)) diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index c16a1ca0e9e..a378ac4f07c 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -17,6 +17,7 @@ use crate::app_state::AppState; use crate::events; use crate::secret_store::SecretStore; +pub(crate) mod commands; mod item; pub(crate) const RELAY_WS: &str = "wss://greymatterllc.communities.buzz.xyz"; diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 76e737c4306..a533e4c65ce 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -1430,3 +1430,4 @@ fn exported_board_drops_all_validate() { } mod answers; +mod desktop_cards; diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 8cba7354c42..e9c4ff86378 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -3,7 +3,7 @@ use super::*; -fn tap(keys: &Keys, card_hex: &str, emoji: &str, created_at: u64) -> nostr::Event { +pub(super) fn tap(keys: &Keys, card_hex: &str, emoji: &str, created_at: u64) -> nostr::Event { crate::events::build_reaction(nostr::EventId::from_hex(card_hex).unwrap(), emoji) .unwrap() .custom_created_at(nostr::Timestamp::from(created_at)) @@ -19,20 +19,20 @@ fn answered(option: usize, answered_at: u64) -> Option { }) } -fn reply(b: &Bench, evs: &[&nostr::Event]) { +pub(super) fn reply(b: &Bench, evs: &[&nostr::Event]) { *b.relay.query_reply.lock().unwrap() = evs .iter() .map(|e| serde_json::to_value(e).unwrap()) .collect(); } -fn outbox(b: &Bench) -> Option { +pub(super) fn outbox(b: &Bench) -> Option { std::fs::read_to_string(b.root.join("outbox/orchestrator/relay-choice.json")) .ok() .map(|t| serde_json::from_str(&t).unwrap()) } -fn last_query(b: &Bench) -> Value { +pub(super) fn last_query(b: &Bench) -> Value { b.relay.queries.lock().unwrap().last().cloned().unwrap() } @@ -44,7 +44,7 @@ fn board_id(b: &Bench) -> String { b.s.board.as_ref().unwrap().event_id.clone() } -const ITEM: &str = "orchestrator/relay-choice"; +pub(super) const ITEM: &str = "orchestrator/relay-choice"; #[test] fn pick_answer_rules() { diff --git a/desktop/src-tauri/src/bench/tests/desktop_cards.rs b/desktop/src-tauri/src/bench/tests/desktop_cards.rs new file mode 100644 index 00000000000..322b67e61c5 --- /dev/null +++ b/desktop/src-tauri/src/bench/tests/desktop_cards.rs @@ -0,0 +1,273 @@ +//! Desktop cards: bench_cards' snapshot and bench_answer's relay half, +//! through the fake relay and Bench harness in tests.rs. + +use super::answers::{last_query, outbox, reply, tap, ITEM}; +use super::*; +use crate::bench::commands::{answer_card, cards, plan_answer, AnswerPlan}; + +fn plan(add: Option<&'static str>, delete: &[&str]) -> AnswerPlan { + AnswerPlan { + add, + delete: delete.iter().map(|d| d.to_string()).collect(), + } +} + +#[test] +fn plan_answer_rules() { + let k = OPTION_EMOJI; + let r = |id: &str, c: &str| (id.to_string(), c.to_string()); + // Choose on a bare card. + assert_eq!(plan_answer(&[], Some(1), 2), plan(Some(k[1]), &[])); + // Switch: the new keycap, then the old one goes. + let one = [r("a", k[0])]; + assert_eq!(plan_answer(&one, Some(1), 2), plan(Some(k[1]), &["a"])); + // Re-choose the same: nothing to do. + assert_eq!(plan_answer(&one, Some(0), 2), AnswerPlan::default()); + // Re-choose one that is already there under another: only the delete. + let both = [r("a", k[0]), r("b", k[1])]; + assert_eq!(plan_answer(&both, Some(0), 2), plan(None, &["b"])); + // Clear: every keycap goes. + assert_eq!(plan_answer(&both, None, 2), plan(None, &["a", "b"])); + // Foreign content stays: a heart, a keycap past this card's options and + // an unqualified keycap. + let noise = [ + r("h", "\u{2764}\u{FE0F}"), + r("x", k[2]), + r("u", "1\u{20E3}"), + ]; + assert_eq!(plan_answer(&noise, Some(0), 2), plan(Some(k[0]), &[])); + assert_eq!(plan_answer(&noise, None, 2), AnswerPlan::default()); + // Out of range plans nothing, not even the clear; more than four options + // clamp to the four keycaps. + assert_eq!(plan_answer(&both, Some(2), 2), AnswerPlan::default()); + assert_eq!(plan_answer(&both, Some(4), 9), AnswerPlan::default()); + assert_eq!(plan_answer(&[], Some(3), 9), plan(Some(k[3]), &[])); +} + +#[tokio::test] +async fn bench_answer_posts_owner_keycaps_and_the_tick_records_them() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + let owner = b.owner_hex(); + let stranger = Keys::generate(); + let noise = [ + tap(&b.ctx.owner, &card, "\u{2764}\u{FE0F}", b.now), + tap(&stranger, &card, OPTION_EMOJI[1], b.now), + tap(&b.ctx.owner, &"ab".repeat(32), OPTION_EMOJI[1], b.now), + ]; + let mut relay: Vec = noise.to_vec(); + // One bench_answer: the posts it made, as events the relay now holds. + async fn answer(b: &Bench, option: Option) -> Vec { + let before = b.posts().len(); + answer_card(&b.ctx, &b.s, ITEM, option, b.now) + .await + .unwrap(); + b.posts()[before..] + .iter() + .map(|(e, tag)| { + assert_eq!(*tag, None, "owner posts carry no auth tag"); + serde_json::from_value(e.clone()).unwrap() + }) + .collect() + } + let kinds = |evs: &[nostr::Event]| evs.iter().map(|e| e.kind.as_u16()).collect::>(); + + // First answer: exactly one owner kind 7, tagged like build_reaction. + reply(&b, &relay.iter().collect::>()); + let posted = answer(&b, Some(1)).await; + assert_eq!(kinds(&posted), [7]); + let first = posted[0].clone(); + assert_eq!(first.pubkey.to_hex(), owner); + assert_eq!(first.content, OPTION_EMOJI[1]); + assert_eq!( + first.tags, + tap(&b.ctx.owner, &card, OPTION_EMOJI[1], 0).tags, + "tags exactly like events::build_reaction" + ); + // The poll's own filter, narrowed to the owner. + assert_eq!( + last_query(&b), + json!([{ + "kinds": [7], "authors": [owner], "#h": [b.channel()], "#e": [card], + "limit": 500, + }]) + ); + relay.push(first.clone()); + reply(&b, &relay.iter().collect::>()); + b.tick().await.unwrap(); + let item = &b.s.items[ITEM]; + assert_eq!( + item.answer + .as_ref() + .map(|a| (a.option, a.reaction_id.clone())), + Some((1, first.id.to_hex())) + ); + assert_eq!(item.recorded, Some(1)); + assert_eq!(outbox(&b).unwrap()["option"], json!(2)); + + // Re-choosing the same option publishes nothing. + assert!(answer(&b, Some(1)).await.is_empty()); + + // Switch: the new keycap first, then a kind 5 on the old one. + let posted = answer(&b, Some(0)).await; + assert_eq!(kinds(&posted), [7, 5]); + let second = posted[0].clone(); + assert_eq!(second.content, OPTION_EMOJI[0]); + assert_eq!( + second.tags, + tap(&b.ctx.owner, &card, OPTION_EMOJI[0], 0).tags + ); + let del = serde_json::to_value(&posted[1]).unwrap(); + assert_eq!(posted[1].pubkey.to_hex(), owner); + assert_eq!(del["tags"], json!([["e", first.id.to_hex()]])); + relay.retain(|e| e.id != first.id); + relay.push(second.clone()); + reply(&b, &relay.iter().collect::>()); + b.tick().await.unwrap(); + assert_eq!(b.s.items[ITEM].answer.as_ref().map(|a| a.option), Some(0)); + assert_eq!(outbox(&b).unwrap()["option"], json!(1)); + + // Clear: only the owner's keycap goes; the heart, the stranger's tap and + // the reaction on another event stay. + let posted = answer(&b, None).await; + assert_eq!(kinds(&posted), [5]); + let del = serde_json::to_value(&posted[0]).unwrap(); + assert_eq!(del["tags"], json!([["e", second.id.to_hex()]])); + relay.retain(|e| e.id != second.id); + reply(&b, &relay.iter().collect::>()); + b.tick().await.unwrap(); + let item = &b.s.items[ITEM]; + assert!(item.answer.is_none() && item.recorded.is_none()); + assert!(outbox(&b).is_none()); +} + +#[tokio::test] +async fn bench_answer_refuses_what_it_cannot_answer() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let no_options = [("severity", Value::Null), ("options", Value::Null)]; + let status = check( + &[ + [ + ("id", json!("st")), + ("kind", json!("status")), + ("state", json!("done")), + ] + .as_slice(), + &no_options, + ] + .concat(), + ) + .unwrap(); + let uncarded = check(&[("id", json!("uncarded"))]).unwrap(); + // A card that still shows an older Drop of its item. + let mut stale = check(&[("id", json!("stale"))]).unwrap(); + stale.card = Some(Posted { + hash: "ffff.2".to_string(), + ..b.card(ITEM) + }); + for i in [status, uncarded, stale] { + b.s.items.insert(i.id.clone(), i); + } + let posts = b.posts().len(); + let queries = b.relay.queries.lock().unwrap().len(); + for (id, option, want) in [ + ("orchestrator/nope", Some(0), "no Bench item"), + ("orchestrator/st", Some(0), "not a pending item"), + ("orchestrator/uncarded", Some(0), "no live card"), + ("orchestrator/stale", None, "no live card"), + (ITEM, Some(2), "out of range"), + ] { + let err = answer_card(&b.ctx, &b.s, id, option, b.now) + .await + .unwrap_err(); + assert!(err.contains(want), "{id}: {err}"); + } + b.s.owner_pubkey = "bb".repeat(32); + let err = answer_card(&b.ctx, &b.s, ITEM, Some(0), b.now) + .await + .unwrap_err(); + assert!(err.contains("owner identity changed"), "{err}"); + assert_eq!(b.posts().len(), posts); + assert_eq!(b.relay.queries.lock().unwrap().len(), queries); +} + +#[tokio::test] +async fn bench_cards_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let off = serde_json::to_value(cards(&dir.path().join("missing"))).unwrap(); + assert_eq!(off, json!({ "enabled": false })); + // Enabled before the first tick: no state.json yet. + let fresh = serde_json::to_value(cards(dir.path())).unwrap(); + assert_eq!(fresh["enabled"], json!(true)); + assert_eq!(fresh["ownerPubkey"], Value::Null); + assert_eq!(fresh["items"], json!([])); + + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + let owner_tap = tap(&b.ctx.owner, &card, OPTION_EMOJI[1], b.now); + reply(&b, &[&owner_tap]); + b.tick().await.unwrap(); + b.s.writer_pubkey = b.writer_hex(); + b.s.last_error = Some("relay unreachable".to_string()); + save_state(&b.root, &b.s, &mut String::new()).unwrap(); + let snap = serde_json::to_value(cards(&b.root)).unwrap(); + let keys = |v: &Value| { + let mut k: Vec = v.as_object().unwrap().keys().cloned().collect(); + k.sort(); + k + }; + let top = keys(&snap); + assert_eq!( + top, + [ + "channelId", + "enabled", + "items", + "lastError", + "lastTick", + "ownerPubkey", + "writerPubkey" + ] + ); + assert_eq!(snap["channelId"], json!(b.channel())); + assert_eq!(snap["writerPubkey"], json!(b.writer_hex())); + assert_eq!(snap["ownerPubkey"], json!(b.owner_hex())); + assert_eq!(snap["lastError"], json!("relay unreachable")); + assert_eq!(snap["lastTick"], json!(NOW)); + let item = &snap["items"][0]; + assert_eq!( + keys(item), + [ + "answer", + "area", + "body", + "cardEventId", + "deadline", + "decidedBy", + "id", + "kind", + "links", + "options", + "order", + "recorded", + "severity", + "state", + "title", + "validity", + "writer" + ] + ); + assert_eq!(item["id"], json!(ITEM)); + assert_eq!(item["kind"], json!("pending")); + assert_eq!(item["severity"], json!("critical")); + assert_eq!(item["validity"], json!("current")); + assert_eq!(item["options"], json!(["Stay", "Move"])); + assert_eq!(item["cardEventId"], json!(card)); + assert_eq!(item["answer"], json!({ "option": 1, "answeredAt": b.now })); + assert_eq!(item["recorded"], json!(1)); + // Only a live card is offered for answering. + b.s.items.get_mut(ITEM).unwrap().hash = "ffff".to_string(); + save_state(&b.root, &b.s, &mut String::new()).unwrap(); + let snap = serde_json::to_value(cards(&b.root)).unwrap(); + assert_eq!(snap["items"][0]["cardEventId"], Value::Null); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index af245ae9685..73b44662df6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -864,6 +864,8 @@ pub fn run() { archive::sync::announce_archive_sync_epoch, archive::sync::start_archive_sync, archive::sync::stop_archive_sync, + bench::commands::bench_cards, + bench::commands::bench_answer, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] From ba1f4d4a0f47349349d15dbf3a8b91f9e47c2456 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 07:50:34 -0500 Subject: [PATCH 08/28] feat(bench): readable card text on the phone The phone draws a fence as 13px monospace that never wraps, so a card's title and options were a sideways scroll. The title is now one bold line and the options a numbered list, both outside the fence; only the agent-written summary stays fenced. CARD_FORMAT 3 reposts each live unanswered card once, quietly; answered cards keep their answer. plain_title becomes plain_text(t, max), shared by board lines and card title, options and link labels. Holes closed on the way: '_' formed emphasis on the desktop; ';' let an entity such as # decode into a live #channel or buzz:// link there; a dropped character could join w[w]w. into an autolink, so rejected characters now become spaces. Text after a card's '1. ' never opens a nested block on either app: it starts at a letter, digit or quote, a leading 1. or 1) loses its mark, and trailing dashes go (gpt_markdown draws any line ending in --- as a rule). Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/item.rs | 87 +++++++++++++------- desktop/src-tauri/src/bench/tests.rs | 31 ++++--- desktop/src-tauri/src/bench/tests/answers.rs | 77 +++++++++++++++-- 3 files changed, 148 insertions(+), 47 deletions(-) diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 01399fd6a09..4983606f7f5 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -427,19 +427,37 @@ pub(crate) fn summary(body: &str) -> String { } } -/// Board lines sit outside any fence, where both apps linkify bare URLs and -/// `[x](url)`: keep only characters that can form neither. +/// Text outside any fence: board lines and a card's title, options and link +/// labels. Both apps render markdown there and the phone has no backslash +/// escape, so anything outside a plainly inert set becomes a space: `_` +/// pairs into emphasis on the desktop, `;` closes an entity (`#`) the +/// desktop decodes into a live `#channel` or `buzz://` link, and a space +/// rather than nothing so a dropped character never joins `w[w]w.` into an +/// autolink. It also never opens a block after a card's `1. ` (both apps +/// nest blocks in a list item): it starts at a letter, digit or quote, a +/// leading `1.` or `1)` loses its mark, and trailing dashes go (the phone +/// draws any line ending in `---` as a rule). +pub(crate) fn plain_text(t: &str, max: usize) -> String { + let inert = |c: char| c.is_ascii_alphanumeric() || " ,.:!?'\"()&+-".contains(c); + let spaced: String = t.chars().map(|c| if inert(c) { c } else { ' ' }).collect(); + let words = spaced.split_whitespace().collect::>().join(" "); + let mut s = words + .trim_start_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '"' || c == '\'')) + .to_string(); + let digits = s.bytes().take_while(u8::is_ascii_digit).count(); + let b = s.as_bytes(); + if digits > 0 + && matches!(b.get(digits), Some(b'.' | b')')) + && matches!(b.get(digits + 1), None | Some(b' ')) + { + s.remove(digits); + } + let s: String = s.chars().take(max).collect(); + s.trim_end_matches(['-', ' ']).to_string() +} + pub(crate) fn plain_title(t: &str) -> String { - let kept: String = t - .chars() - .filter(|c| c.is_ascii_alphanumeric() || " ,.;:!?'\"()&+_-".contains(*c)) - .collect(); - kept.split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(BOARD_TITLE_MAX) - .collect() + plain_text(t, BOARD_TITLE_MAX) } pub(crate) fn severity_rank(s: Option) -> u8 { @@ -478,8 +496,9 @@ fn state_name(s: StatusState) -> &'static str { /// Bump when render_card's text changes: every live unanswered card is then /// reposted once, without a ping, with the new text (publish (b) retires it /// as stale). An answered card gets the new text through its Recorded edit -/// and keeps its answer and its 24 h keep. -pub(crate) const CARD_FORMAT: u32 = 2; +/// and keeps its answer and its 24 h keep. 3: title and options moved out of +/// the fence, which the phone draws as unwrapped monospace. +pub(crate) const CARD_FORMAT: u32 = 3; /// The version a card is posted and tagged at: `.`. /// Cards posted before CARD_FORMAT existed carry the bare Drop hash. @@ -487,29 +506,41 @@ pub(crate) fn card_hash(item: &Item) -> String { format!("{}.{CARD_FORMAT}", item.hash) } -/// Card text: marker line, one fence holding title/summary/options, then the -/// validated deadline and links outside the fence so the phone can open them, -/// and on an unanswered pending card the line that says how to answer. +/// Card text: marker line, the title in bold, the summary alone in a fence +/// (agent prose stays inert), the options as a numbered list, then the +/// validated deadline and links so the phone can open them, and on an +/// unanswered pending card the line that says how to answer. Everything +/// outside the fence but the url goes through plain_text. pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { - let mut inner = format!("{}\n\n{}", item.title, summary(&item.body)); - if item.kind == ItemKind::Pending { - inner.push('\n'); - for (i, o) in item.options.iter().enumerate() { - inner.push_str(&format!("\n{}. {o}", i + 1)); - } - } let mut out = format!( - "{} \u{b7} {} \u{b7} {}\n{}", + "{} \u{b7} {} \u{b7} {}", marker(item), item.area, - item.writer, - fence(&inner) + item.writer ); + let title = plain_text(&item.title, usize::MAX); + // An empty one would print `****`, a rule on both apps. + if !title.is_empty() { + out.push_str(&format!("\n**{title}**")); + } + if !item.body.trim().is_empty() { + out.push('\n'); + out.push_str(&fence(&summary(&item.body))); + } + if item.kind == ItemKind::Pending { + for (i, o) in item.options.iter().enumerate() { + out.push_str(&format!("\n{}. {}", i + 1, plain_text(o, usize::MAX))); + } + // A blank line ends the list, or the desktop folds the lines below + // into the last option. + out.push('\n'); + } if let Some(d) = &item.deadline { out.push_str(&format!("\ndue {d}")); } for l in &item.links { - out.push_str(&format!("\n{}: {}", l.label, l.url)); + let label = plain_text(&l.label, usize::MAX); + out.push_str(&format!("\n{label}: {}", l.url)); } if item.kind == ItemKind::Pending && item.answer.is_none() { out.push_str("\nTap one number below to answer."); diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 76e737c4306..b7269aced98 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -340,37 +340,46 @@ fn render_card_golden() { assert_eq!( render_card(&item, "14:05"), "🔴 · dreamforge · orchestrator\n\ + **Relay choice**\n\ ```text\n\ - Relay choice\n\ - \n\ Stay or move?\n\ - \n\ + ```\n\ 1. Stay\n\ 2. Move\n\ - ```\n\ + \n\ due 2026-09-20T00:00:00Z\n\ plan: https://github.com/QuicksilverSlick/buzz\n\ Tap one number below to answer.\n\ as of 14:05" ); + let decision = check(&[ + ("kind", json!("decision")), + ("decidedBy", json!("claude")), + ("severity", Value::Null), + ("options", Value::Null), + ]) + .unwrap(); + assert_eq!( + render_card(&decision, "14:05"), + "🧭 · dreamforge · orchestrator\n**Relay choice**\n```text\nStay or move?\n```\nas of 14:05" + ); } #[test] fn render_card_keeps_mentions_inside_the_fence() { // validate() refuses these strings; a card built from state must still - // keep them where neither app resolves them. + // keep them where neither app resolves them. The title is outside, so + // it loses its '@'; a bare nostr: is plain text on both apps. let mut item = check(&[]).unwrap(); item.title = "@Honey nostr:npub1abc".to_string(); item.body = "ping @Honey nostr:npub1abc".to_string(); let card = render_card(&item, "14:05"); let (head, rest) = card.split_once("```text\n").unwrap(); let (inner, tail) = rest.split_once("\n```").unwrap(); - assert!(inner.contains("@Honey nostr:npub1abc")); + assert_eq!(inner, "ping @Honey nostr:npub1abc"); + assert!(head.ends_with("\n**Honey nostr:npub1abc**\n"), "{head:?}"); for outside in [head, tail] { - assert!( - !outside.contains('@') && !outside.contains("nostr:"), - "{outside:?}" - ); + assert!(!outside.contains('@'), "{outside:?}"); } } @@ -466,7 +475,7 @@ fn render_board_orders_links_and_folds() { - 🟠 Phone alerts (dreamforge) (no card yet)\n\ \n\ Decisions\n\ - - 🧭 label(https:evil.example) Stay (dreamforge) (decided on claude.ai)\n\ + - 🧭 label (https: evil.example) Stay (dreamforge) (decided on claude.ai)\n\ - 🧭 Own relay later (dreamforge) (claude)\n\ \n\ Status\n\ diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 8cba7354c42..ec92664d804 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -135,7 +135,7 @@ fn render_card_recorded_trailer() { assert_eq!( card, format!( - "{}\nRecorded: 2. Move x(https:evil.example) · 14:05", + "{}\nRecorded: 2. Move x (https: evil.example) · 14:05", plain.replace(&format!("\n{HINT}"), "") ) ); @@ -192,7 +192,7 @@ fn render_board_answered_section() { \n\ Answered, waiting for action (2)\n\ - 🔴 Earlier tap (dreamforge) → 1. Stay · buzz://message?channel={UUID}&id={hex64}\n\ - - 🔴 Relay choice (dreamforge) → 2. Move x(https:evil.example)" + - 🔴 Relay choice (dreamforge) → 2. Move x (https: evil.example)" ) ); assert!(!board.contains("https://") && !board.contains('[')); @@ -204,7 +204,7 @@ fn render_board_answered_section() { ); let late = render_board(&all, UUID, "14:05", 100 + ANSWER_KEEP_SECS + 1); assert!( - late.ends_with("→ 2. Move x(https:evil.example) 🟠 24h"), + late.ends_with("→ 2. Move x (https: evil.example) 🟠 24h"), "{late:?}" ); assert_ne!(board_hash(&board), board_hash(&late)); @@ -590,7 +590,7 @@ fn answer_hint_only_on_unanswered_pending_cards() { let mut pending = check(&[]).unwrap(); let card = render_card(&pending, "14:05"); assert!( - card.ends_with(&format!("```\n{HINT}\nas of 14:05")), + card.ends_with(&format!("2. Move\n\n{HINT}\nas of 14:05")), "{card}" ); pending.answer = answered(0, 1); @@ -620,6 +620,13 @@ fn answer_hint_only_on_unanswered_pending_cards() { #[tokio::test] async fn older_card_format_is_reposted_once_and_answers_survive() { + // Before CARD_FORMAT (the bare Drop hash), and format 2 (text in a fence). + for older in ["", ".2"] { + older_card_format_case(older).await; + } +} + +async fn older_card_format_case(older: &str) { const OTHER: &str = "orchestrator/other"; let mut b = seeded_bench(Gate::Open, &[]).await; b.drop_file( @@ -628,10 +635,9 @@ async fn older_card_format_is_reposted_once_and_answers_survive() { &drop_json(&[("title", json!("Other"))]), ); b.settle(3).await; - // Both cards as an older build tagged them (the bare Drop hash), one - // tapped before the upgrade. + // Both cards as an older build tagged them, one tapped before the upgrade. for i in b.s.items.values_mut() { - i.card.as_mut().unwrap().hash = i.hash.clone(); + i.card.as_mut().unwrap().hash = format!("{}{older}", i.hash); } let (kept, old) = (b.card(ITEM).event_id, b.card(OTHER).event_id); let owner_tap = tap(&b.ctx.owner, &kept, OPTION_EMOJI[1], b.now); @@ -651,7 +657,8 @@ async fn older_card_format_is_reposted_once_and_answers_survive() { tag_value(&posts[2].0, "client").unwrap(), format!("bench:card:{OTHER}@{}", card_hash(&b.s.items[OTHER])) ); - assert!(text(&posts[2].0).contains(HINT)); + assert!(text(&posts[0].0).contains("\n**Relay choice**\n")); + assert!(text(&posts[2].0).contains("\n**Other**\n") && text(&posts[2].0).contains(HINT)); assert_eq!(tag_value(&posts[2].0, "p"), None); assert!(!b.s.items[OTHER].quiet_repost); // The answered card, its answer and its outbox file stay. @@ -700,3 +707,57 @@ async fn recorded_answer_on_older_format_card_survives_the_refresh() { assert_eq!(b.card(ITEM).event_id, card.event_id); assert!(b.s.items[ITEM].answer.is_some() && outbox(&b).is_some()); } + +// ── Card text the phone can read ──────────────────────────────────────── + +#[test] +fn render_card_text_outside_the_fence_is_inert() { + // Everything either app gives meaning to, an entity, a dropped character + // that would join "www.", and a trailing rule. + let active = "[a](https://x.example) ![i](u) @Honey #general |a|b| *em* _em_ `c` \ + ~~s~~ u \\(x\\) \\[y\\] $z$ #ch w[w]w.x.example ---"; + let mut item = check(&[]).unwrap(); + item.title = format!("# {active}"); + item.options = ["- ", "> ", "1. ", "(x) "] + .map(|p| format!("{p}{active}")) + .to_vec(); + for answer in [None, answered(2, 1)] { + item.answer = answer; + let card = render_card(&item, "14:05"); + let (head, rest) = card.split_once("```text\n").unwrap(); + let (_, tail) = rest.split_once("\n```\n").unwrap(); + let lines: Vec<&str> = head.lines().chain(tail.lines()).skip(1).collect(); + assert!( + lines[0].starts_with("**") && lines[0].ends_with("**"), + "{card}" + ); + for (i, n) in ["1. ", "2. ", "3. ", "4. "].iter().enumerate() { + assert!(lines[i + 1].starts_with(n), "{card}"); + } + for line in lines.iter().filter(|l| !l.is_empty()) { + let text = line + .strip_prefix("**") + .and_then(|l| l.strip_suffix("**")) + .or_else(|| { + line.get(3..) + .filter(|_| line.as_bytes().get(1) == Some(&b'.')) + }) + .unwrap_or(line); + assert!( + !text.contains(|c: char| "[]@#|*_`~<>\\$;/".contains(c)), + "{text:?}" + ); + assert!(!text.contains("www.") && !text.ends_with('-'), "{text:?}"); + // No nested list, quote, heading or radio button either. + assert!( + text.starts_with(|c: char| c.is_ascii_alphanumeric()), + "{text:?}" + ); + let lead = text.split(' ').next().unwrap(); + let ordinal = lead + .strip_suffix(['.', ')']) + .is_some_and(|n| n.bytes().all(|b| b.is_ascii_digit())); + assert!(!ordinal, "{text:?}"); + } + } +} From 94753f31657c56be388f63fa956a9c1143497369 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 08:08:33 -0500 Subject: [PATCH 09/28] feat(desktop): clickable Bench cards in #bench A message in #bench renders as a Bench card instead of its markdown body only when the snapshot's channel matches, its id is a live card's cardEventId and the Bench writer key signed it (benchRowState). Every other row renders as before. The card is built from Card, Badge and Button. It has a severity chip, the title and body as plain React text (agent text stays inert), the due date, https and buzz:// links, and one button per option. A click calls bench_answer with the 0-based option, or null on the recorded option to undo. The card shows Saving... until the snapshot's recorded value moves, and shows an inline error on a command error or after 15 s. Once answered it shows Recorded: n. option with the time. Decision and status items, and stale, superseded or non-owner cards, are read-only. useBenchCards polls bench_cards every 3 s while visible, using one shared timer for all rows instead of one react-query interval per observer. Rows outside #bench read the cached snapshot. Keycap reaction pills stay under the card because they are the record shared with the phone. The E2E bridge mocks bench_cards (configurable) and bench_answer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- .../features/bench/lib/benchCards.test.mjs | 533 ++++++++++++++++++ desktop/src/features/bench/lib/benchCards.ts | 485 ++++++++++++++++ .../src/features/bench/ui/BenchCard.test.mjs | 360 ++++++++++++ desktop/src/features/bench/ui/BenchCard.tsx | 278 +++++++++ desktop/src/features/bench/useBenchCards.ts | 128 +++++ .../src/features/messages/ui/MessageRow.tsx | 22 + desktop/src/testing/e2eBridge.ts | 11 + 7 files changed, 1817 insertions(+) create mode 100644 desktop/src/features/bench/lib/benchCards.test.mjs create mode 100644 desktop/src/features/bench/lib/benchCards.ts create mode 100644 desktop/src/features/bench/ui/BenchCard.test.mjs create mode 100644 desktop/src/features/bench/ui/BenchCard.tsx create mode 100644 desktop/src/features/bench/useBenchCards.ts diff --git a/desktop/src/features/bench/lib/benchCards.test.mjs b/desktop/src/features/bench/lib/benchCards.test.mjs new file mode 100644 index 00000000000..9636a8d28f1 --- /dev/null +++ b/desktop/src/features/bench/lib/benchCards.test.mjs @@ -0,0 +1,533 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BENCH_SAVE_TIMEOUT_MESSAGE, + INITIAL_BENCH_ANSWER_STATE, + answerTarget, + benchAnswerReducer, + benchCardMode, + benchDeadlineSeconds, + benchOptionViews, + benchRecordedLine, + benchRowState, + benchSeverityChip, + benchStateLabel, + benchValidityLabel, + createPollGroup, + indexBenchCards, + isBenchCandidate, + safeBenchLinks, +} from "./benchCards.ts"; + +const CHANNEL = "8f6c1a52-3f0b-4f5e-9a51-0e2f5c7d9b10"; +const OTHER_CHANNEL = "1d2c3b4a-0000-4000-8000-000000000000"; +const WRITER = "a".repeat(64); +const OWNER = "b".repeat(64); +const STRANGER = "c".repeat(64); +const CARD_ID = "e".repeat(64); + +function card(overrides = {}) { + return { + id: "dreamforge/ship-it", + writer: "dreamforge", + kind: "pending", + title: "Ship the release?", + body: "Two options.", + area: "release", + severity: "attention", + options: ["Ship it", "Hold"], + state: null, + decidedBy: null, + deadline: null, + links: [], + order: 0, + validity: "current", + cardEventId: CARD_ID, + answer: null, + recorded: null, + ...overrides, + }; +} + +function snapshot(items = [card()], overrides = {}) { + return { + enabled: true, + channelId: CHANNEL, + writerPubkey: WRITER, + ownerPubkey: OWNER, + lastError: null, + lastTick: "2026-10-12T10:00:00Z", + items, + ...overrides, + }; +} + +function row(overrides = {}) { + return { + channelId: CHANNEL, + id: CARD_ID, + kind: 9, + signerPubkey: WRITER, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// The card selection guard +// --------------------------------------------------------------------------- + +test("guard: a live card in #bench signed by the writer is a Bench card", () => { + const cards = snapshot(); + const state = benchRowState(cards, row()); + assert.equal(state.inBenchChannel, true); + assert.equal(state.match?.card, cards.items[0]); + assert.equal(state.match?.ownerPubkey, OWNER); +}); + +test("guard: the signer comparison ignores hex case", () => { + const state = benchRowState( + snapshot(), + row({ signerPubkey: WRITER.toUpperCase() }), + ); + assert.notEqual(state.match, null); +}); + +test("guard: any other signer renders as an ordinary message", () => { + for (const signerPubkey of [OWNER, STRANGER, undefined, ""]) { + const state = benchRowState(snapshot(), row({ signerPubkey })); + assert.equal(state.match, null, `signer ${signerPubkey}`); + assert.equal(state.inBenchChannel, true); + } +}); + +test("guard: the same event id in another channel is not a Bench card", () => { + const state = benchRowState(snapshot(), row({ channelId: OTHER_CHANNEL })); + assert.deepEqual(state, { inBenchChannel: false, match: null }); + assert.equal(benchRowState(snapshot(), row({ channelId: null })).match, null); +}); + +test("guard: a message id that is no live card renders as before", () => { + const state = benchRowState(snapshot(), row({ id: "f".repeat(64) })); + assert.equal(state.match, null); + assert.equal(state.inBenchChannel, true, "still polls inside #bench"); +}); + +test("guard: an item without a live card claims no message", () => { + const cards = snapshot([card({ cardEventId: null })]); + assert.equal(benchRowState(cards, row()).match, null); +}); + +test("guard: a disabled, missing or not-yet-ticked Bench matches nothing", () => { + for (const cards of [ + { enabled: false }, + undefined, + null, + snapshot(undefined, { channelId: "" }), + ]) { + assert.deepEqual( + benchRowState(cards, row({ channelId: cards?.channelId ?? CHANNEL })), + { inBenchChannel: false, match: null }, + ); + } +}); + +test("guard: an empty writer key matches no signer", () => { + const cards = snapshot(undefined, { writerPubkey: "" }); + assert.equal(benchRowState(cards, row({ signerPubkey: "" })).match, null); +}); + +test("guard: other kinds and optimistic sends never become cards", () => { + assert.equal(benchRowState(snapshot(), row({ kind: 40008 })).match, null); + assert.equal(benchRowState(snapshot(), row({ pending: true })).match, null); + assert.notEqual( + benchRowState(snapshot(), row({ kind: undefined })).match, + null, + ); + assert.equal(isBenchCandidate(row({ kind: 7 })), false); + assert.equal(isBenchCandidate(row()), true); +}); + +test("index: live cards by event id, cached per snapshot", () => { + const cards = snapshot([ + card(), + card({ id: "w/two", cardEventId: "1".repeat(64) }), + card({ id: "w/none", cardEventId: null }), + ]); + const index = indexBenchCards(cards); + assert.deepEqual([...index.keys()], [CARD_ID, "1".repeat(64)]); + assert.equal(indexBenchCards(cards), index, "same snapshot, same Map"); + assert.equal(indexBenchCards(null).size, 0); +}); + +test("index: an event id claimed by two items maps to neither", () => { + const cards = snapshot([card(), card({ id: "w/dupe" })]); + assert.equal(indexBenchCards(cards).has(CARD_ID), false); + assert.equal(benchRowState(cards, row()).match, null); +}); + +// --------------------------------------------------------------------------- +// Interactivity +// --------------------------------------------------------------------------- + +test("mode: a current pending card answers for the owner", () => { + assert.deepEqual(benchCardMode(card(), OWNER, OWNER), { + interactive: true, + readOnlyReason: null, + }); + assert.equal( + benchCardMode(card(), OWNER, OWNER.toUpperCase()).interactive, + true, + ); + assert.equal( + benchCardMode(card(), OWNER, undefined).interactive, + true, + "an unknown viewer is left to bench_answer's own owner check", + ); +}); + +test("mode: read-only for another viewer, no owner, stale or superseded", () => { + const cases = [ + [card(), OWNER, STRANGER, /Only the Bench owner/], + [card(), null, OWNER, /no owner/], + [card({ validity: "superseded" }), OWNER, OWNER, /Superseded/], + [card({ validity: "suspected-stale" }), OWNER, OWNER, /May be stale/], + [card({ cardEventId: null }), OWNER, OWNER, /no longer live/], + ]; + for (const [c, owner, viewer, reason] of cases) { + const mode = benchCardMode(c, owner, viewer); + assert.equal(mode.interactive, false); + assert.match(mode.readOnlyReason ?? "", reason); + } +}); + +test("mode: decision and status cards are never interactive", () => { + for (const kind of ["decision", "status"]) { + assert.deepEqual(benchCardMode(card({ kind }), OWNER, OWNER), { + interactive: false, + readOnlyReason: null, + }); + } +}); + +// --------------------------------------------------------------------------- +// Chips and labels +// --------------------------------------------------------------------------- + +test("severity: each level has a readable label and a theme badge variant", () => { + assert.deepEqual(benchSeverityChip("critical"), { + label: "Critical", + variant: "destructive", + }); + assert.deepEqual(benchSeverityChip("attention"), { + label: "Attention", + variant: "warning", + }); + assert.deepEqual(benchSeverityChip("info"), { + label: "Info", + variant: "info", + }); + assert.deepEqual(benchSeverityChip("good"), { + label: "Good", + variant: "success", + }); +}); + +test("severity: none, unknown and prototype keys give no chip", () => { + for (const value of [ + null, + undefined, + "", + "urgent", + "toString", + "__proto__", + ]) { + assert.equal(benchSeverityChip(value), null, String(value)); + } +}); + +test("labels: status states and validity", () => { + assert.equal(benchStateLabel("in-progress"), "In progress"); + assert.equal(benchStateLabel("not-started"), "Not started"); + assert.equal(benchStateLabel(null), null); + assert.equal(benchStateLabel("constructor"), null); + assert.equal(benchValidityLabel("current"), null); + assert.equal(benchValidityLabel("superseded"), "Superseded"); + assert.equal(benchValidityLabel("suspected-stale"), "May be stale"); +}); + +// --------------------------------------------------------------------------- +// Link filter +// --------------------------------------------------------------------------- + +test("links: https and buzz:// survive with their scheme", () => { + const message = `buzz://message?channel=${CHANNEL}&id=${CARD_ID}`; + assert.deepEqual( + safeBenchLinks([ + { label: "PR 42", url: "https://github.com/block/buzz/pull/42" }, + { label: "Card", url: message }, + ]), + [ + { + label: "PR 42", + url: "https://github.com/block/buzz/pull/42", + scheme: "https", + }, + { label: "Card", url: message, scheme: "buzz" }, + ], + ); +}); + +test("links: every other scheme, credentials and odd bytes are dropped", () => { + const dropped = [ + "javascript:alert(1)", + "JavaScript:alert(1)", + "data:text/html,x", + "http://github.com/x", + "file:///C:/Windows/win.ini", + "vbscript:msgbox", + "https://user:pass@github.com/x", + "https://user@github.com/x", + "https:github.com/x", + "https:///nohost", + "https://github.com/a b", + "https://github.com/\tx", + "https://github.com\\@evil.example/x", + "https://gith\u00fcb.com/x", + " https://github.com/x", + "buzz:message?channel=x", + "//github.com/x", + "", + ]; + for (const url of dropped) { + assert.deepEqual(safeBenchLinks([{ label: "x", url }]), [], url); + } +}); + +test("links: blank labels and malformed entries are dropped", () => { + assert.deepEqual( + safeBenchLinks([ + { label: " ", url: "https://github.com/x" }, + { label: "ok", url: 42 }, + null, + { url: "https://github.com/x" }, + ]), + [], + ); + assert.deepEqual(safeBenchLinks(undefined), []); +}); + +// --------------------------------------------------------------------------- +// Pending / recorded reducer +// --------------------------------------------------------------------------- + +function click(state, option, recorded) { + return benchAnswerReducer(state, { type: "click", option, recorded }); +} + +test("answerTarget: clicking the recorded option undoes it", () => { + assert.equal(answerTarget(1, null), 1); + assert.equal(answerTarget(1, 0), 1); + assert.equal(answerTarget(1, 1), null); +}); + +test("reducer: a click saves until the snapshot records the target", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, null); + assert.deepEqual(saving, { + attempt: 1, + phase: "saving", + clicked: 1, + target: 1, + error: null, + }); + assert.equal( + benchAnswerReducer(saving, { type: "recorded", recorded: null }), + saving, + "an unchanged snapshot keeps it saving", + ); + assert.equal( + benchAnswerReducer(saving, { type: "recorded", recorded: 0 }), + saving, + "another option recorded (a newer phone tap) is not this click", + ); + assert.deepEqual( + benchAnswerReducer(saving, { type: "recorded", recorded: 1 }), + { ...INITIAL_BENCH_ANSWER_STATE, attempt: 1 }, + ); +}); + +test("reducer: undo waits for the card to show no answer", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 0, 0); + assert.equal(saving.target, null); + assert.equal(saving.clicked, 0); + assert.equal( + benchAnswerReducer(saving, { type: "recorded", recorded: null }).phase, + "idle", + ); +}); + +test("reducer: clicks while saving are ignored", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, null); + assert.equal(click(saving, 0, null), saving); +}); + +test("reducer: a click right after the record lands starts a new attempt", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, null); + // The stored state still says saving, but the snapshot already shows 1. + const next = click(saving, 1, 1); + assert.equal(next.attempt, 2); + assert.equal(next.target, null, "the second click undoes the first"); +}); + +test("reducer: command errors and the 15 s timeout fail only their attempt", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, null); + assert.equal( + benchAnswerReducer(saving, { type: "timedOut", attempt: 0 }), + saving, + "a stale timer is ignored", + ); + const timedOut = benchAnswerReducer(saving, { type: "timedOut", attempt: 1 }); + assert.equal(timedOut.phase, "failed"); + assert.equal(timedOut.error, BENCH_SAVE_TIMEOUT_MESSAGE); + + const failed = benchAnswerReducer(saving, { + type: "commandFailed", + attempt: 1, + message: "owner identity changed; refusing to answer", + }); + assert.equal(failed.phase, "failed"); + assert.equal( + failed.error, + "Couldn't save: owner identity changed; refusing to answer", + ); + assert.equal( + benchAnswerReducer(failed, { type: "timedOut", attempt: 1 }), + failed, + "a failed attempt does not time out again", + ); +}); + +test("reducer: a late record clears the error; a retry starts fresh", () => { + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, null); + const failed = benchAnswerReducer(saving, { type: "timedOut", attempt: 1 }); + assert.equal( + benchAnswerReducer(failed, { type: "recorded", recorded: 1 }).phase, + "idle", + ); + const retry = click(failed, 0, null); + assert.equal(retry.phase, "saving"); + assert.equal(retry.attempt, 2); + assert.equal(retry.error, null); +}); + +// --------------------------------------------------------------------------- +// Option buttons and the Recorded line +// --------------------------------------------------------------------------- + +test("options: the recorded option is selected; the clicked one is pending", () => { + const recorded = card({ recorded: 0, answer: { option: 0, answeredAt: 1 } }); + const idle = benchOptionViews(recorded, INITIAL_BENCH_ANSWER_STATE, true); + assert.deepEqual( + idle.map((o) => [o.number, o.text, o.selected, o.pending, o.disabled]), + [ + ["1", "Ship it", true, false, false], + ["2", "Hold", false, false, false], + ], + ); + const saving = click(INITIAL_BENCH_ANSWER_STATE, 1, 0); + const busy = benchOptionViews(recorded, saving, true); + assert.deepEqual( + busy.map((o) => [o.selected, o.pending, o.disabled]), + [ + [true, false, true], + [false, true, false], + ], + ); +}); + +test("options: read-only cards disable every button", () => { + const views = benchOptionViews(card(), INITIAL_BENCH_ANSWER_STATE, false); + assert.ok(views.every((o) => o.disabled)); +}); + +test("recorded line: option number, text and the answer time", () => { + const formatTime = (s) => `t${s}`; + assert.equal(benchRecordedLine(card(), formatTime), null); + assert.equal( + benchRecordedLine( + card({ recorded: 1, answer: { option: 1, answeredAt: 99 } }), + formatTime, + ), + "Recorded: 2. Hold \u00b7 t99", + ); + assert.equal( + benchRecordedLine( + card({ recorded: 1, answer: { option: 0, answeredAt: 99 } }), + formatTime, + ), + "Recorded: 2. Hold", + "no time when the answer has already moved on", + ); + assert.equal(benchRecordedLine(card({ recorded: 5 }), formatTime), null); +}); + +test("deadline: RFC 3339 to unix seconds, null when unparseable", () => { + assert.equal(benchDeadlineSeconds("2026-10-14T15:00:00Z"), 1_791_990_000); + assert.equal(benchDeadlineSeconds("soon"), null); + assert.equal(benchDeadlineSeconds(null), null); +}); + +// --------------------------------------------------------------------------- +// Shared poll timer +// --------------------------------------------------------------------------- + +function fakeTimers() { + const timers = new Map(); + let next = 1; + return { + timers, + api: { + setInterval: (callback, ms) => { + const id = next++; + timers.set(id, { callback, ms }); + return id; + }, + clearInterval: (id) => timers.delete(id), + }, + tick() { + for (const { callback } of [...timers.values()]) callback(); + }, + }; +} + +test("poll group: one timer for many subscribers, one fetch per tick", () => { + const fake = fakeTimers(); + const group = createPollGroup(3_000, () => true, fake.api); + const calls = []; + const releaseA = group.retain(() => calls.push("a")); + const releaseB = group.retain(() => calls.push("b")); + assert.equal(fake.timers.size, 1); + assert.equal([...fake.timers.values()][0].ms, 3_000); + fake.tick(); + assert.deepEqual(calls, ["a"]); + releaseA(); + fake.tick(); + assert.deepEqual(calls, ["a", "b"], "the next subscriber takes over"); + releaseB(); + assert.equal(fake.timers.size, 0, "the last release stops the timer"); + assert.equal(group.size, 0); +}); + +test("poll group: no fetch while the window is hidden", () => { + const fake = fakeTimers(); + let visible = false; + const group = createPollGroup(3_000, () => visible, fake.api); + let calls = 0; + const release = group.retain(() => calls++); + fake.tick(); + assert.equal(calls, 0); + visible = true; + fake.tick(); + assert.equal(calls, 1); + release(); +}); diff --git a/desktop/src/features/bench/lib/benchCards.ts b/desktop/src/features/bench/lib/benchCards.ts new file mode 100644 index 00000000000..ed37d6ab7bb --- /dev/null +++ b/desktop/src/features/bench/lib/benchCards.ts @@ -0,0 +1,485 @@ +/** + * Pure logic for Bench decision cards in #bench: the wire types of the + * `bench_cards` snapshot, the guard that decides which timeline message is a + * live Bench card, chip/label mapping, the link filter, and the reducer that + * tracks a click until the Bench records it. + * + * Everything an agent wrote (title, body, options, link labels) stays plain + * text: callers render it as React text, never markdown or HTML. + */ + +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type BenchSeverity = "critical" | "attention" | "info" | "good"; +export type BenchItemKind = "pending" | "decision" | "status"; +export type BenchStatusState = + | "done" + | "in-progress" + | "active" + | "planned" + | "not-started" + | "blocked" + | "inactive"; +export type BenchValidity = "current" | "suspected-stale" | "superseded"; +export type BenchLink = { label: string; url: string }; + +export type BenchCard = { + id: string; + writer: string; + kind: BenchItemKind; + title: string; + body: string; + area: string; + severity: BenchSeverity | null; + options: string[]; + state: BenchStatusState | null; + decidedBy: string | null; + deadline: string | null; + links: BenchLink[]; + order: number; + validity: BenchValidity; + /** The live card's kind 9 id; null when the item has no live card. */ + cardEventId: string | null; + /** 0-based option; answeredAt is the Bench clock in unix seconds. */ + answer: { option: number; answeredAt: number } | null; + /** 0-based option the card text shows as Recorded; null = unanswered. */ + recorded: number | null; +}; + +export type BenchSnapshot = { + enabled: true; + /** "" before the Bench's first tick. */ + channelId: string; + writerPubkey: string; + ownerPubkey: string | null; + lastError: string | null; + lastTick: string; + items: BenchCard[]; +}; + +export type BenchCards = { enabled: false } | BenchSnapshot; + +export type BenchCardMatch = { + card: BenchCard; + ownerPubkey: string | null; +}; + +/** What a timeline row needs from the snapshot. */ +export type BenchRowState = { + /** The row sits in #bench, so the snapshot should stay fresh. */ + inBenchChannel: boolean; + match: BenchCardMatch | null; +}; + +export type BenchMessageRef = { + channelId: string | null | undefined; + id: string; + kind?: number; + pending?: boolean; + /** Raw event signer, not a relay-delegated display author. */ + signerPubkey?: string; +}; + +const KIND_STREAM_MESSAGE = 9; +const NOT_A_BENCH_ROW: BenchRowState = { inBenchChannel: false, match: null }; + +const cardIndexCache = new WeakMap< + BenchSnapshot, + ReadonlyMap +>(); + +/** + * Live cards by their kind 9 event id. An id claimed by two items maps to + * neither: the guard would rather render the message as plain chat. + */ +export function indexBenchCards( + snapshot: BenchSnapshot | null | undefined, +): ReadonlyMap { + if (!snapshot) return new Map(); + const cached = cardIndexCache.get(snapshot); + if (cached) return cached; + const index = new Map(); + const ambiguous = new Set(); + for (const item of snapshot.items) { + const id = item.cardEventId; + if (!id) continue; + if (index.has(id)) ambiguous.add(id); + else index.set(id, item); + } + for (const id of ambiguous) index.delete(id); + cardIndexCache.set(snapshot, index); + return index; +} + +export function enabledBenchSnapshot( + cards: BenchCards | null | undefined, +): BenchSnapshot | null { + return cards?.enabled === true ? cards : null; +} + +export function isBenchChannel( + snapshot: BenchSnapshot | null | undefined, + channelId: string | null | undefined, +): boolean { + return Boolean( + snapshot && snapshot.channelId !== "" && channelId === snapshot.channelId, + ); +} + +/** Whether a row could be a Bench card at all, before reading the snapshot. */ +export function isBenchCandidate(message: BenchMessageRef): boolean { + return ( + Boolean(message.channelId) && + Boolean(message.signerPubkey) && + message.pending !== true && + (message.kind === undefined || message.kind === KIND_STREAM_MESSAGE) + ); +} + +/** + * The guard. A message renders as a Bench card only when it is in the + * snapshot's channel, its id is a live card's event id, and the Bench writer + * key signed it. Everything else renders exactly as before. + */ +export function benchRowState( + cards: BenchCards | null | undefined, + message: BenchMessageRef, +): BenchRowState { + const snapshot = enabledBenchSnapshot(cards); + if (!snapshot || !isBenchChannel(snapshot, message.channelId)) { + return NOT_A_BENCH_ROW; + } + if (!isBenchCandidate(message) || !snapshot.writerPubkey) { + return { inBenchChannel: true, match: null }; + } + const card = indexBenchCards(snapshot).get(message.id); + const signer = normalizePubkey(message.signerPubkey ?? ""); + const signedByWriter = signer === normalizePubkey(snapshot.writerPubkey); + return { + inBenchChannel: true, + match: + card && signedByWriter + ? { card, ownerPubkey: snapshot.ownerPubkey } + : null, + }; +} + +export type BenchCardMode = { + /** Option buttons answer the card. */ + interactive: boolean; + /** Why a pending card's options are read-only. */ + readOnlyReason: string | null; +}; + +export function benchCardMode( + card: BenchCard, + ownerPubkey: string | null, + viewerPubkey: string | undefined, +): BenchCardMode { + if (card.kind !== "pending") { + return { interactive: false, readOnlyReason: null }; + } + const readOnly = (readOnlyReason: string) => ({ + interactive: false, + readOnlyReason, + }); + if (!card.cardEventId) return readOnly("This card is no longer live."); + if (card.validity === "superseded") { + return readOnly("Superseded: this card is being retired."); + } + if (card.validity === "suspected-stale") { + return readOnly("May be stale: this card is being retired."); + } + if (!ownerPubkey) return readOnly("The Bench has no owner yet."); + if ( + viewerPubkey !== undefined && + normalizePubkey(viewerPubkey) !== normalizePubkey(ownerPubkey) + ) { + return readOnly("Only the Bench owner can answer this card."); + } + return { interactive: true, readOnlyReason: null }; +} + +export type BenchChipVariant = "destructive" | "warning" | "info" | "success"; + +const SEVERITY_CHIPS: Record< + BenchSeverity, + { label: string; variant: BenchChipVariant } +> = { + critical: { label: "Critical", variant: "destructive" }, + attention: { label: "Attention", variant: "warning" }, + info: { label: "Info", variant: "info" }, + good: { label: "Good", variant: "success" }, +}; + +export function benchSeverityChip( + severity: string | null | undefined, +): { label: string; variant: BenchChipVariant } | null { + return severity && Object.hasOwn(SEVERITY_CHIPS, severity) + ? SEVERITY_CHIPS[severity as BenchSeverity] + : null; +} + +const KIND_LABELS: Record = { + pending: "Pending", + decision: "Decision", + status: "Status", +}; + +const STATE_LABELS: Record = { + done: "Done", + "in-progress": "In progress", + active: "Active", + planned: "Planned", + "not-started": "Not started", + blocked: "Blocked", + inactive: "Inactive", +}; + +const VALIDITY_LABELS: Record = { + current: null, + "suspected-stale": "May be stale", + superseded: "Superseded", +}; + +function lookup( + table: Record, + key: string | null | undefined, +): string | null { + return key && Object.hasOwn(table, key) ? table[key as K] : null; +} + +export function benchKindLabel(kind: string): string | null { + return lookup(KIND_LABELS, kind); +} + +export function benchStateLabel(state: string | null): string | null { + return lookup(STATE_LABELS, state); +} + +export function benchValidityLabel(validity: string): string | null { + return lookup(VALIDITY_LABELS, validity); +} + +export type SafeBenchLink = BenchLink & { scheme: "https" | "buzz" }; + +/** + * Only https (without credentials) and buzz:// URLs survive; anything with + * whitespace or control characters, or any other scheme (javascript:, data:, + * http:, file:) is dropped. The Bench validates links too; this is the + * renderer's own check. + */ +export function safeBenchLinks( + links: readonly BenchLink[] | null | undefined, +): SafeBenchLink[] { + const out: SafeBenchLink[] = []; + for (const link of links ?? []) { + if (typeof link?.url !== "string" || typeof link.label !== "string") { + continue; + } + const label = link.label.trim(); + const scheme = linkScheme(link.url); + if (label && scheme) out.push({ label, url: link.url, scheme }); + } + return out; +} + +function linkScheme(url: string): SafeBenchLink["scheme"] | null { + // Printable ASCII only (no spaces, controls or look-alike Unicode), and + // like the Bench itself no '@' or '\', which URL parsers read as + // credentials or as '/'. + if (!/^[\x21-\x7e]+$/.test(url) || /[@\\]/.test(url)) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.username || parsed.password) return null; + // The raw text must already name the host: `https:host` and `https:///host` + // parse, but only after the parser repairs them. + if (parsed.protocol === "https:" && /^https:\/\/[^/]/.test(url)) { + return parsed.hostname ? "https" : null; + } + if (parsed.protocol === "buzz:" && url.startsWith("buzz://")) return "buzz"; + return null; +} + +/** How long a click waits for the snapshot to show it recorded. */ +export const BENCH_SAVE_TIMEOUT_MS = 15_000; +export const BENCH_SAVE_TIMEOUT_MESSAGE = + "Not recorded after 15 s. The Bench may be offline; try again."; + +export type BenchAnswerState = { + /** Bumped on every accepted click, so a late timer or reply is ignored. */ + attempt: number; + phase: "idle" | "saving" | "failed"; + /** The option button that was clicked. */ + clicked: number | null; + /** The `recorded` value the click is waiting for (null = cleared). */ + target: number | null; + error: string | null; +}; + +export type BenchAnswerAction = + | { type: "click"; option: number; recorded: number | null } + | { type: "commandFailed"; attempt: number; message: string } + | { type: "timedOut"; attempt: number } + | { type: "recorded"; recorded: number | null }; + +export const INITIAL_BENCH_ANSWER_STATE: BenchAnswerState = { + attempt: 0, + phase: "idle", + clicked: null, + target: null, + error: null, +}; + +/** Clicking the recorded option undoes it; any other option answers. */ +export function answerTarget( + option: number, + recorded: number | null, +): number | null { + return option === recorded ? null : option; +} + +function settle( + state: BenchAnswerState, + recorded: number | null, +): BenchAnswerState { + return state.phase !== "idle" && recorded === state.target + ? { ...state, phase: "idle", clicked: null, target: null, error: null } + : state; +} + +export function benchAnswerReducer( + state: BenchAnswerState, + action: BenchAnswerAction, +): BenchAnswerState { + switch (action.type) { + case "recorded": + return settle(state, action.recorded); + case "click": { + const base = settle(state, action.recorded); + if (base.phase === "saving") return base; + return { + attempt: base.attempt + 1, + phase: "saving", + clicked: action.option, + target: answerTarget(action.option, action.recorded), + error: null, + }; + } + case "commandFailed": + case "timedOut": { + if (state.phase !== "saving" || state.attempt !== action.attempt) { + return state; + } + return { + ...state, + phase: "failed", + error: + action.type === "timedOut" + ? BENCH_SAVE_TIMEOUT_MESSAGE + : `Couldn't save: ${action.message}`, + }; + } + } +} + +export type BenchOptionView = { + index: number; + /** "1", "2", ... like the phone's keycaps. */ + number: string; + text: string; + selected: boolean; + pending: boolean; + disabled: boolean; +}; + +export function benchOptionViews( + card: BenchCard, + state: BenchAnswerState, + interactive: boolean, +): BenchOptionView[] { + const saving = state.phase === "saving"; + return card.options.map((text, index) => { + const pending = saving && state.clicked === index; + return { + index, + number: String(index + 1), + text, + selected: card.recorded === index, + pending, + disabled: !interactive || (saving && !pending), + }; + }); +} + +/** "Recorded: 2. Ship it · 14:05", or null when nothing is recorded. */ +export function benchRecordedLine( + card: BenchCard, + formatTime: (unixSeconds: number) => string, +): string | null { + const recorded = card.recorded; + if (recorded === null || recorded < 0) return null; + const text = card.options[recorded]; + if (text === undefined) return null; + const line = `Recorded: ${recorded + 1}. ${text}`; + return card.answer?.option === recorded + ? `${line} · ${formatTime(card.answer.answeredAt)}` + : line; +} + +/** RFC 3339 deadline to unix seconds; null when it does not parse. */ +export function benchDeadlineSeconds(deadline: string | null): number | null { + if (!deadline) return null; + const ms = Date.parse(deadline); + return Number.isFinite(ms) ? Math.floor(ms / 1_000) : null; +} + +type IntervalApi = { + setInterval: (callback: () => void, ms: number) => unknown; + clearInterval: (id: unknown) => void; +}; + +/** + * One timer for any number of subscribers. React Query gives every observer + * its own refetchInterval timer, so N rows polling one key would fetch N + * times per interval; rows retain this instead. Each tick calls the oldest + * live subscriber's refetch, and only while `isVisible()`. + */ +export function createPollGroup( + intervalMs: number, + isVisible: () => boolean, + timers: IntervalApi = { + setInterval: (callback, ms) => globalThis.setInterval(callback, ms), + clearInterval: (id) => + globalThis.clearInterval(id as ReturnType), + }, +) { + const subscribers = new Set<{ refetch: () => void }>(); + let timer: unknown = null; + return { + retain(refetch: () => void): () => void { + const subscriber = { refetch }; + subscribers.add(subscriber); + if (timer === null) { + timer = timers.setInterval(() => { + if (!isVisible()) return; + subscribers.values().next().value?.refetch(); + }, intervalMs); + } + return () => { + subscribers.delete(subscriber); + if (subscribers.size === 0 && timer !== null) { + timers.clearInterval(timer); + timer = null; + } + }; + }, + get size() { + return subscribers.size; + }, + }; +} diff --git a/desktop/src/features/bench/ui/BenchCard.test.mjs b/desktop/src/features/bench/ui/BenchCard.test.mjs new file mode 100644 index 00000000000..2fe38caa653 --- /dev/null +++ b/desktop/src/features/bench/ui/BenchCard.test.mjs @@ -0,0 +1,360 @@ +/** + * Production-seam pins for the Bench card in #bench: the real `BenchCard` + * and `useBenchCardForMessage` mounted against a mocked Tauri IPC boundary. + * + * - Agent-written text stays inert: markdown and HTML in the title, body and + * options render as literal text, and only https links become anchors. + * - A click sends `bench_answer` with the 0-based option, shows "Saving..." + * until the snapshot's `recorded` moves, then "Recorded: n. option"; a + * click on the recorded option sends `option: null` (undo). + * - A command error shows inline; read-only cards send nothing. + * - The row hook matches only the live card signed by the writer key. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const clients = []; +const ipcCalls = []; +const ipcHandlers = new Map(); + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let waitFor; +let createElement; +let QueryClient; +let QueryClientProvider; +let TooltipProvider; +let BenchCard; +let useBenchCardForMessage; + +const CHANNEL = "8f6c1a52-3f0b-4f5e-9a51-0e2f5c7d9b10"; +const WRITER = "a".repeat(64); +const OWNER = "b".repeat(64); +const CARD_ID = "e".repeat(64); + +function card(overrides = {}) { + return { + id: "dreamforge/ship-it", + writer: "dreamforge", + kind: "pending", + title: "Ship the release?", + body: "Two options.", + area: "release", + severity: "critical", + options: ["Ship it", "Hold"], + state: null, + decidedBy: null, + deadline: null, + links: [], + order: 0, + validity: "current", + cardEventId: CARD_ID, + answer: null, + recorded: null, + ...overrides, + }; +} + +function wrap(element) { + const client = new QueryClient({ + defaultOptions: { + mutations: { gcTime: 0 }, + queries: { gcTime: 0, retry: false }, + }, + }); + clients.push(client); + return createElement( + QueryClientProvider, + { client }, + createElement(TooltipProvider, null, element), + ); +} + +function benchCard(props) { + return createElement(BenchCard, { + ownerPubkey: OWNER, + viewerPubkey: OWNER, + ...props, + }); +} + +function answerCalls() { + return ipcCalls.filter((c) => c.cmd === "bench_answer").map((c) => c.args); +} + +function optionButton(name) { + return screen.getByRole("button", { name: new RegExp(`^${name}`) }); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key === "window" || key === "document" || key === "globalThis") + continue; + const value = dom.window[key]; + if ( + typeof value === "function" && + /^(HTML|SVG)|Element$|Event$|EventTarget$|^Node|^Document|Observer$/.test( + key, + ) + ) { + globalThis[key] = value; + } + } + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + ipcCalls.push({ cmd, args }); + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen, waitFor } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip")); + ({ BenchCard } = await import("./BenchCard.tsx")); + ({ useBenchCardForMessage } = await import("../useBenchCards.ts")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + ipcHandlers.clear(); + ipcCalls.length = 0; +}); + +after(() => dom.window.close()); + +test("agent text renders inert: no markdown, no HTML, no unsafe links", async () => { + const hostile = card({ + title: "**Bold** x # heading", + body: "[click](https://evil.example) ![](https://evil.example/p.png)\n@alice #general `code` ~~strike~~ \\(x\\)", + options: ["Ship *now*", "| a | b |"], + links: [ + { label: "PR 42", url: "https://github.com/block/buzz/pull/42" }, + { label: "Evil", url: "javascript:alert(1)" }, + { label: "Plain", url: "http://github.com/x" }, + ], + }); + let view; + await act(async () => { + view = render(wrap(benchCard({ card: hostile }))); + }); + const root = view.container.querySelector("[data-testid='bench-card']"); + assert.ok(root, "the card renders"); + for (const tag of ["img", "b", "i", "strong", "em", "code", "del", "h1"]) { + assert.equal(root.querySelector(tag), null, `no <${tag}> from agent text`); + } + assert.ok(root.textContent.includes("**Bold** x # heading")); + assert.ok(root.textContent.includes("")); + assert.ok(root.textContent.includes("[click](https://evil.example)")); + assert.ok(root.textContent.includes("Ship *now*")); + const anchors = [...root.querySelectorAll("a")]; + assert.deepEqual( + anchors.map((a) => [a.textContent, a.getAttribute("href")]), + [["PR 42", "https://github.com/block/buzz/pull/42"]], + ); + assert.ok(screen.getByText("Critical"), "severity chip has a readable label"); +}); + +test("a click answers, shows Saving... until recorded, then the Recorded line", async () => { + ipcHandlers.set("bench_answer", () => Promise.resolve(null)); + let view; + await act(async () => { + view = render(wrap(benchCard({ card: card() }))); + }); + + await act(async () => { + fireEvent.click(optionButton("1. Ship it")); + }); + assert.deepEqual(answerCalls(), [ + { itemId: "dreamforge/ship-it", option: 0 }, + ]); + assert.ok(screen.getByText("Saving...")); + assert.equal(optionButton("1. Ship it").getAttribute("aria-busy"), "true"); + assert.equal(optionButton("2. Hold").disabled, true, "others wait"); + + // A second click while saving sends nothing. + await act(async () => { + fireEvent.click(optionButton("1. Ship it")); + }); + assert.equal(answerCalls().length, 1); + + const answeredAt = Math.floor(Date.now() / 1000); + await act(async () => { + view.rerender( + wrap( + benchCard({ + card: card({ recorded: 0, answer: { option: 0, answeredAt } }), + }), + ), + ); + }); + assert.equal(screen.queryByText("Saving..."), null); + const recorded = screen.getByTestId("bench-card-recorded"); + assert.match(recorded.textContent, /^Recorded: 1\. Ship it · \S/); + assert.equal(optionButton("1. Ship it").getAttribute("aria-pressed"), "true"); + assert.equal(optionButton("2. Hold").getAttribute("aria-pressed"), "false"); +}); + +test("clicking the recorded option undoes it with option: null", async () => { + ipcHandlers.set("bench_answer", () => Promise.resolve(null)); + const answered = card({ recorded: 1, answer: { option: 1, answeredAt: 1 } }); + let view; + await act(async () => { + view = render(wrap(benchCard({ card: answered }))); + }); + await act(async () => { + fireEvent.click(optionButton("2. Hold")); + }); + assert.deepEqual(answerCalls(), [ + { itemId: "dreamforge/ship-it", option: null }, + ]); + assert.ok(screen.getByText("Saving...")); + await act(async () => { + view.rerender(wrap(benchCard({ card: card() }))); + }); + assert.equal(screen.queryByText("Saving..."), null); + assert.equal(screen.queryByTestId("bench-card-recorded"), null); +}); + +test("a command error shows inline and re-enables the options", async () => { + ipcHandlers.set("bench_answer", () => + Promise.reject("owner identity changed; refusing to answer"), + ); + await act(async () => { + render(wrap(benchCard({ card: card() }))); + }); + await act(async () => { + fireEvent.click(optionButton("2. Hold")); + }); + assert.equal(screen.queryByText("Saving..."), null); + assert.ok( + screen.getByText( + "Couldn't save: owner identity changed; refusing to answer", + ), + ); + assert.equal(optionButton("1. Ship it").disabled, false); +}); + +test("read-only cards send nothing; decision cards have no options", async () => { + await act(async () => { + render(wrap(benchCard({ card: card(), viewerPubkey: "c".repeat(64) }))); + }); + assert.equal(optionButton("1. Ship it").disabled, true); + assert.ok(screen.getByText("Only the Bench owner can answer this card.")); + await act(async () => { + fireEvent.click(optionButton("1. Ship it")); + }); + assert.equal(answerCalls().length, 0); + cleanup(); + + await act(async () => { + render( + wrap( + benchCard({ + card: card({ kind: "decision", options: [], decidedBy: "russell" }), + }), + ), + ); + }); + assert.equal(screen.queryAllByRole("button").length, 0); + assert.ok(screen.getByText("Decided by russell")); +}); + +function RowProbe({ message, onMatch }) { + onMatch(useBenchCardForMessage(message)); + return null; +} + +test("row hook: only the writer-signed live card in #bench is a match", async () => { + const snapshot = { + enabled: true, + channelId: CHANNEL, + writerPubkey: WRITER, + ownerPubkey: OWNER, + lastError: null, + lastTick: "2026-10-12T10:00:00Z", + items: [card()], + }; + ipcHandlers.set("bench_cards", () => Promise.resolve(snapshot)); + const rows = { + card: { channelId: CHANNEL, id: CARD_ID, kind: 9, signerPubkey: WRITER }, + forged: { channelId: CHANNEL, id: CARD_ID, kind: 9, signerPubkey: OWNER }, + elsewhere: { + channelId: "1d2c3b4a-0000-4000-8000-000000000000", + id: CARD_ID, + kind: 9, + signerPubkey: WRITER, + }, + }; + const seen = {}; + await act(async () => { + render( + wrap( + createElement( + "div", + null, + ...Object.entries(rows).map(([name, message]) => + createElement(RowProbe, { + key: name, + message, + onMatch: (match) => { + seen[name] = match; + }, + }), + ), + ), + ), + ); + }); + await waitFor(() => assert.equal(seen.card?.card.id, "dreamforge/ship-it")); + assert.equal(seen.card?.ownerPubkey, OWNER); + assert.equal(seen.forged, null, "another signer is plain chat"); + assert.equal(seen.elsewhere, null, "another channel is plain chat"); + assert.equal( + ipcCalls.filter((c) => c.cmd === "bench_cards").length, + 1, + "rows share one snapshot fetch", + ); +}); diff --git a/desktop/src/features/bench/ui/BenchCard.tsx b/desktop/src/features/bench/ui/BenchCard.tsx new file mode 100644 index 00000000000..598d87ba990 --- /dev/null +++ b/desktop/src/features/bench/ui/BenchCard.tsx @@ -0,0 +1,278 @@ +import * as React from "react"; +import { Check } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + type BenchCard as BenchCardData, + type BenchOptionView, + type SafeBenchLink, + BENCH_SAVE_TIMEOUT_MS, + INITIAL_BENCH_ANSWER_STATE, + benchAnswerReducer, + benchCardMode, + benchDeadlineSeconds, + benchKindLabel, + benchOptionViews, + benchRecordedLine, + benchSeverityChip, + benchStateLabel, + benchValidityLabel, + safeBenchLinks, +} from "@/features/bench/lib/benchCards"; +import { useBenchAnswer } from "@/features/bench/useBenchCards"; +import { openPopoverLink } from "@/features/messages/lib/openPopoverLink"; +import { cn } from "@/shared/lib/cn"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { ExternalLinkAnchor } from "@/shared/ui/markdown/ExternalLinkAnchor"; +import { Spinner } from "@/shared/ui/spinner"; + +function formatBenchTime(unixSeconds: number): string { + return formatItemTimestamp(unixSeconds, { withTime: true }); +} + +/** + * A Bench decision card inside #bench. Every string here was written by an + * agent, so it renders as plain React text: never markdown, never HTML. + * Option buttons answer through `bench_answer` with the owner's keycap; the + * card shows "Saving..." until the Bench's tick records it. + */ +export function BenchCard({ + card, + ownerPubkey, + viewerPubkey, +}: { + card: BenchCardData; + ownerPubkey: string | null; + viewerPubkey?: string; +}) { + const { interactive, readOnlyReason } = benchCardMode( + card, + ownerPubkey, + viewerPubkey, + ); + const [storedState, dispatch] = React.useReducer( + benchAnswerReducer, + INITIAL_BENCH_ANSWER_STATE, + ); + // Settle against the snapshot in render too, so the frame that shows the + // new recorded answer never also shows "Saving...". + const state = benchAnswerReducer(storedState, { + type: "recorded", + recorded: card.recorded, + }); + React.useEffect(() => { + dispatch({ type: "recorded", recorded: card.recorded }); + }, [card.recorded]); + + const saving = state.phase === "saving"; + const attempt = state.attempt; + React.useEffect(() => { + if (!saving) return; + const timer = window.setTimeout( + () => dispatch({ type: "timedOut", attempt }), + BENCH_SAVE_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [attempt, saving]); + + const { mutateAsync: answer } = useBenchAnswer(); + const handleOption = (option: number) => { + const action = { + type: "click", + option, + recorded: card.recorded, + } as const; + const next = benchAnswerReducer(storedState, action); + // Unchanged attempt: a save is still in flight, so the click is ignored. + if (next.attempt === storedState.attempt) return; + dispatch(action); + answer({ itemId: card.id, option: next.target }).catch((error: unknown) => { + dispatch({ + type: "commandFailed", + attempt: next.attempt, + message: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const severity = benchSeverityChip(card.severity); + const validity = benchValidityLabel(card.validity); + const stateLabel = + card.kind === "status" ? benchStateLabel(card.state) : null; + const deadline = benchDeadlineSeconds(card.deadline); + const links = safeBenchLinks(card.links); + const options = + card.kind === "pending" ? benchOptionViews(card, state, interactive) : []; + const recordedLine = benchRecordedLine(card, formatBenchTime); + const meta = [benchKindLabel(card.kind), card.area, card.writer] + .filter(Boolean) + .join(" · "); + + return ( + +
+ {severity ? ( + {severity.label} + ) : null} + {stateLabel ? {stateLabel} : null} + {validity ? {validity} : null} + + {meta} + +
+

+ {card.title} +

+ {card.body ? ( +

+ {card.body} +

+ ) : null} + {card.deadline ? ( +

+ Due {deadline === null ? card.deadline : formatBenchTime(deadline)} +

+ ) : null} + {card.kind === "decision" && card.decidedBy ? ( +

+ Decided by {card.decidedBy} +

+ ) : null} + {links.length > 0 ? ( +
    + {links.map((link) => ( +
  • + +
  • + ))} +
+ ) : null} + {options.length > 0 ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : null} +
+ {saving ? ( +

Saving...

+ ) : recordedLine ? ( +

+ {recordedLine} +

+ ) : null} + {state.phase === "failed" && state.error ? ( +

+ {state.error} +

+ ) : null} + {readOnlyReason ? ( +

{readOnlyReason}

+ ) : null} +
+
+ ); +} + +function BenchOptionButton({ + onSelect, + option, +}: { + onSelect: (index: number) => void; + option: BenchOptionView; +}) { + const label = `${option.number}. ${option.text}`; + return ( + + ); +} + +/** https opens like any chat link (OS browser, masked-URL tooltip). */ +function BenchLinkAnchor({ link }: { link: SafeBenchLink }) { + if (link.scheme === "buzz") return ; + return ( + + {link.label} + + ); +} + +/** buzz://message opens in-app; any other buzz:// URL goes to the OS. */ +function BuzzBenchLinkAnchor({ link }: { link: SafeBenchLink }) { + const { goChannel } = useAppNavigation(); + return ( + { + event.preventDefault(); + openPopoverLink(link.url, { + openExternal: (url) => { + void openUrl(url).catch(() => { + toast.error("Failed to open link"); + }); + }, + openMessageLink: (target) => { + void goChannel(target.channelId, { + messageId: target.messageId, + threadRootId: target.threadRootId, + }); + }, + }); + }} + title={link.url} + > + {link.label} + + ); +} diff --git a/desktop/src/features/bench/useBenchCards.ts b/desktop/src/features/bench/useBenchCards.ts new file mode 100644 index 00000000000..40e1bed3e1b --- /dev/null +++ b/desktop/src/features/bench/useBenchCards.ts @@ -0,0 +1,128 @@ +import * as React from "react"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; + +import { + type BenchCardMatch, + type BenchCards, + type BenchMessageRef, + type BenchRowState, + benchRowState, + createPollGroup, + enabledBenchSnapshot, + indexBenchCards, + isBenchCandidate, +} from "@/features/bench/lib/benchCards"; +import { invokeTauri } from "@/shared/api/tauri"; +import { isDocumentVisible } from "@/shared/lib/useDocumentVisible"; + +export const BENCH_CARDS_QUERY_KEY = ["bench-cards"] as const; +/** Snapshot cadence while a Bench surface is on screen and visible. */ +export const BENCH_POLL_INTERVAL_MS = 3_000; +/** Off #bench the snapshot only gates rendering, so a mount reuses it. */ +const BENCH_IDLE_STALE_MS = 60_000; + +export type BenchAnswerInput = { + itemId: string; + /** 0-based option; null clears the owner's answer. */ + option: number | null; +}; + +function fetchBenchCards(): Promise { + return invokeTauri("bench_cards"); +} + +const benchCardsQueryOptions = { + queryKey: BENCH_CARDS_QUERY_KEY, + queryFn: fetchBenchCards, + staleTime: BENCH_IDLE_STALE_MS, + // A build or mock without the command must not retry on every row mount. + retry: false, + retryOnMount: false, +} as const; + +// Holds timers only, never Bench data: every subscriber releases on unmount, +// so a community switch (which remounts the tree) leaves nothing behind. +const benchPoll = createPollGroup(BENCH_POLL_INTERVAL_MS, isDocumentVisible); + +function refetchBenchCards(queryClient: QueryClient) { + void queryClient.refetchQueries({ + queryKey: BENCH_CARDS_QUERY_KEY, + exact: true, + }); +} + +/** Keeps the snapshot fresh every 3 s while `active`, with one shared timer. */ +function useBenchPolling(active: boolean) { + const queryClient = useQueryClient(); + React.useEffect(() => { + if (!active) return; + return benchPoll.retain(() => refetchBenchCards(queryClient)); + }, [active, queryClient]); +} + +/** + * The Bench as state.json last recorded it, polled every 3 s while mounted + * and the window is visible, plus live cards by event id and the answer + * mutation. + */ +export function useBenchCards() { + const query = useQuery(benchCardsQueryOptions); + useBenchPolling(true); + const snapshot = enabledBenchSnapshot(query.data); + const cardsByEventId = React.useMemo( + () => indexBenchCards(snapshot), + [snapshot], + ); + const answer = useBenchAnswer(); + return { + snapshot, + cardsByEventId, + error: query.error, + isPending: query.isPending, + answer, + }; +} + +/** + * Answers a card with the owner's keycap (or clears it) through + * `bench_answer`, then refreshes the snapshot. The Bench's own tick records + * the answer, so callers wait for `recorded` to change, not for this. + */ +export function useBenchAnswer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ itemId, option }: BenchAnswerInput) => + invokeTauri("bench_answer", { itemId, option }), + onSettled: () => + queryClient.invalidateQueries({ queryKey: BENCH_CARDS_QUERY_KEY }), + }); +} + +/** + * The live Bench card a timeline message is, or null. Only rows in #bench + * poll; every other row reads the cached snapshot and re-renders only if its + * own selection changes. + */ +export function useBenchCardForMessage( + message: BenchMessageRef, +): BenchCardMatch | null { + const { channelId, id, kind, pending, signerPubkey } = message; + const candidate = isBenchCandidate(message); + const select = React.useCallback( + (cards: BenchCards): BenchRowState => + benchRowState(cards, { channelId, id, kind, pending, signerPubkey }), + [channelId, id, kind, pending, signerPubkey], + ); + const { data } = useQuery({ + ...benchCardsQueryOptions, + enabled: candidate, + select, + }); + useBenchPolling(data?.inBenchChannel === true); + return data?.match ?? null; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 0cb002bc5cd..e642e0e731b 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -12,6 +12,8 @@ import { } from "@/features/messages/lib/canSendToChannel"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { BenchCard } from "@/features/bench/ui/BenchCard"; +import { useBenchCardForMessage } from "@/features/bench/useBenchCards"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { MessageAuthorWithIndicators } from "@/features/messages/ui/MessageAuthorWithIndicators"; @@ -374,6 +376,15 @@ export const MessageRow = React.memo( }, [collapseDepthGuideActions]); const getTag = (name: string) => message.tags?.find((tag) => tag[0] === name)?.[1]; + // Non-null only for a live Bench card in #bench signed by the Bench + // writer key (see benchRowState); every other row renders as before. + const benchCard = useBenchCardForMessage({ + channelId, + id: message.id, + kind: message.kind, + pending: message.pending, + signerPubkey: message.signerPubkey, + }); const renderBody = () => { switch (message.kind) { @@ -409,6 +420,15 @@ export const MessageRow = React.memo( /> ); default: { + if (benchCard) { + return ( + + ); + } const waveMessage = parseWaveMessageContent(message.body); if (waveMessage) { return ( @@ -931,6 +951,8 @@ export const MessageRow = React.memo( (prev, next) => prev.message.id === next.message.id && prev.message.pubkey === next.message.pubkey && + // The Bench card guard checks the raw signer. + prev.message.signerPubkey === next.message.signerPubkey && prev.message.body === next.message.body && prev.message.author === next.message.author && prev.message.isAgent === next.message.isAgent && diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8007d1dfca0..93199b1ac95 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -23,6 +23,7 @@ import type { UnreadCatchUpChannelResult } from "@/shared/api/tauriUnreadCatchUp import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; +import type { BenchCards } from "@/features/bench/lib/benchCards"; import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome"; import { _testRegisterKnownAgents, @@ -535,6 +536,8 @@ type E2eConfig = { // snake_case wire shape the Rust backend returns so tests can drive the // LocalArchiveSettingsCard without a real SQLite database. agentMetricArchiveDefaultEnabled?: boolean; + // The `bench_cards` snapshot; absent means the Bench is not enabled. + benchCards?: BenchCards; saveSubscriptions?: Array<{ scope_type: string; scope_value: string; @@ -14972,6 +14975,14 @@ export function maybeInstallE2eTauriMocks() { } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true; + // Message rows read the Bench snapshot to decide which of them are + // Bench cards; without a configured snapshot no row is one. + case "bench_cards": + return activeConfig?.mock?.benchCards ?? { enabled: false }; + // The real command publishes the owner's keycap; the mock accepts it + // and leaves `recorded` to the configured snapshot. + case "bench_answer": + return null; case "set_prevent_sleep_active": return null; case "set_window_vibrancy": From 40b3f96be236021028f4670d98092f1012302d56 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 08:16:06 -0500 Subject: [PATCH 10/28] fix(bench): review: card summary fence holds on the phone and wraps gpt_markdown ends a code block only at a line of exactly three backticks, so the longer fence fence() picks never shielded a body with an inner ``` line: the phone closed the block there, drew any [label](url) after it as a live link, and the Bench's own closing fence then swallowed the options and the answer hint into unwrapped monospace. Backticks in the summary now become U+02CB, so the fence is always three backticks and nothing inside can close it on either app. The phone never wraps a fence and shows about 26 columns of it on a 360 dp screen, so the summary, which holds the question, is hard-wrapped at FENCE_WIDTH (26) on spaces, longer words cut, newlines kept. plain_text puts a space after every ':', so no :shortcode: survives in a title, option, link label or board line for the phone to resolve against any community member's emoji set. It folds curly quotes, en and em dashes and the ellipsis to ASCII first, so "Don't" no longer reads "Don t". validate() rejects an option with no plain letter or digit, so the owner never gets a bare "1." beside a keycap; the 38 exported claude.ai drops still validate. CARD_FORMAT stays 3: format 3 has not shipped. Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/item.rs | 80 ++++++++++++++--- desktop/src-tauri/src/bench/tests.rs | 10 ++- desktop/src-tauri/src/bench/tests/answers.rs | 90 +++++++++++++++++++- 3 files changed, 165 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 4983606f7f5..190007064d7 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -32,6 +32,9 @@ const LABEL_MAX: usize = 40; const URL_MAX: usize = 512; const SUMMARY_MAX: usize = 400; const BOARD_TITLE_MAX: usize = 60; +/// Columns of a card's fence a 360 dp phone shows without scrolling: about +/// 208 dp beside the avatar and code padding, at ~7.8 dp per 13 px glyph. +pub(crate) const FENCE_WIDTH: usize = 26; /// A drop file exactly as a writer may author it. `deny_unknown_fields` is /// the whole "a drop never carries answer/verdict/source/version/card/ @@ -166,8 +169,8 @@ pub(crate) struct Item { pub quiet_repost: bool, } -/// `^[a-z0-9][a-z0-9-]{0,max-1}$`: writer, id and area. Writer and area are -/// the only untrusted strings printed outside the fence on a card. +/// `^[a-z0-9][a-z0-9-]{0,max-1}$`: writer, id and area. Writer and area, +/// like a validated link url, print outside the fence as they are. pub(crate) fn valid_name(s: &str, max: usize) -> bool { let plain = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit(); !s.is_empty() @@ -309,6 +312,14 @@ pub(crate) fn validate( valid_text(&d.body, BODY_MAX, "body")?; for o in &d.options { valid_text(o, OPTION_MAX, "option")?; + // Printed through plain_text: an emoji-only option would leave the + // owner a bare "1." beside its keycap. + if !plain_text(o, usize::MAX) + .bytes() + .any(|b| b.is_ascii_alphanumeric()) + { + return Err("option: no plain letters or digits".to_string()); + } } match d.kind { ItemKind::Pending => { @@ -427,6 +438,33 @@ pub(crate) fn summary(body: &str) -> String { } } +/// Hard-wrap each line at `width` chars on spaces, cutting longer words; +/// runs of spaces and tabs become one space and newlines stay. The phone +/// never wraps a fence: each line is one row it scrolls sideways. +pub(crate) fn wrap(text: &str, width: usize) -> String { + let mut lines = Vec::new(); + for line in text.split('\n') { + let (mut row, mut len) = (String::new(), 0); + for word in line.split_whitespace() { + let chars: Vec = word.chars().collect(); + for piece in chars.chunks(width) { + if len > 0 && len + 1 + piece.len() > width { + lines.push(std::mem::take(&mut row)); + len = 0; + } + if len > 0 { + row.push(' '); + len += 1; + } + row.extend(piece); + len += piece.len(); + } + } + lines.push(row); + } + lines.join("\n") +} + /// Text outside any fence: board lines and a card's title, options and link /// labels. Both apps render markdown there and the phone has no backslash /// escape, so anything outside a plainly inert set becomes a space: `_` @@ -436,10 +474,24 @@ pub(crate) fn summary(body: &str) -> String { /// autolink. It also never opens a block after a card's `1. ` (both apps /// nest blocks in a list item): it starts at a letter, digit or quote, a /// leading `1.` or `1)` loses its mark, and trailing dashes go (the phone -/// draws any line ending in `---` as a rule). +/// draws any line ending in `---` as a rule). Curly quotes, en and em dashes +/// and the ellipsis fold to ASCII first, so "Don’t" never reads "Don t". +/// Nothing but a space follows a `:`: the phone draws `:name:` as the image +/// any community member published under that shortcode. pub(crate) fn plain_text(t: &str, max: usize) -> String { - let inert = |c: char| c.is_ascii_alphanumeric() || " ,.:!?'\"()&+-".contains(c); - let spaced: String = t.chars().map(|c| if inert(c) { c } else { ' ' }).collect(); + let inert = |c: char| c.is_ascii_alphanumeric() || " ,.!?'\"()&+-".contains(c); + let mut spaced = String::with_capacity(t.len()); + for c in t.chars() { + match c { + '\u{2018}' | '\u{2019}' | '\u{02BC}' => spaced.push('\''), + '\u{201C}' | '\u{201D}' => spaced.push('"'), + '\u{2013}' | '\u{2014}' => spaced.push('-'), + '\u{2026}' => spaced.push_str("..."), + ':' => spaced.push_str(": "), + c if inert(c) => spaced.push(c), + _ => spaced.push(' '), + } + } let words = spaced.split_whitespace().collect::>().join(" "); let mut s = words .trim_start_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '"' || c == '\'')) @@ -497,7 +549,8 @@ fn state_name(s: StatusState) -> &'static str { /// reposted once, without a ping, with the new text (publish (b) retires it /// as stale). An answered card gets the new text through its Recorded edit /// and keeps its answer and its 24 h keep. 3: title and options moved out of -/// the fence, which the phone draws as unwrapped monospace. +/// the fence, which the phone draws as unwrapped monospace, and the summary +/// left in it is hard-wrapped. pub(crate) const CARD_FORMAT: u32 = 3; /// The version a card is posted and tagged at: `.`. @@ -507,10 +560,10 @@ pub(crate) fn card_hash(item: &Item) -> String { } /// Card text: marker line, the title in bold, the summary alone in a fence -/// (agent prose stays inert), the options as a numbered list, then the -/// validated deadline and links so the phone can open them, and on an -/// unanswered pending card the line that says how to answer. Everything -/// outside the fence but the url goes through plain_text. +/// (agent prose stays inert) wrapped to FENCE_WIDTH, the options as a +/// numbered list, then the validated deadline and links so the phone can +/// open them, and on an unanswered pending card the line that says how to +/// answer. Everything outside the fence but the url goes through plain_text. pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { let mut out = format!( "{} \u{b7} {} \u{b7} {}", @@ -524,8 +577,13 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out.push_str(&format!("\n**{title}**")); } if !item.body.trim().is_empty() { + // No backtick survives, so the fence is always three: the phone ends + // a block at the first line of exactly three backticks, never at a + // longer fence, and the text after it would render live. U+02CB + // looks the same and is markdown on neither app. + let body = summary(&item.body).replace('`', "\u{02CB}"); out.push('\n'); - out.push_str(&fence(&summary(&item.body))); + out.push_str(&fence(&wrap(&body, FENCE_WIDTH))); } if item.kind == ItemKind::Pending { for (i, o) in item.options.iter().enumerate() { diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index b7269aced98..23a485182d0 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -142,6 +142,14 @@ fn validate_rejects_bad_drops() { "zero width", vec![("options", json!(["Stay\u{200B}", "Move"]))], ), + ( + "emoji-only option", + vec![("options", json!(["Stay", "👍"]))], + ), + ( + "punctuation-only option", + vec![("options", json!(["?!", "Move"]))], + ), ( "buzz with path", link(&format!("buzz://message/x?channel={UUID}&id={hex64}")), @@ -377,7 +385,7 @@ fn render_card_keeps_mentions_inside_the_fence() { let (head, rest) = card.split_once("```text\n").unwrap(); let (inner, tail) = rest.split_once("\n```").unwrap(); assert_eq!(inner, "ping @Honey nostr:npub1abc"); - assert!(head.ends_with("\n**Honey nostr:npub1abc**\n"), "{head:?}"); + assert!(head.ends_with("\n**Honey nostr: npub1abc**\n"), "{head:?}"); for outside in [head, tail] { assert!(!outside.contains('@'), "{outside:?}"); } diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index ec92664d804..8324085c08a 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -713,9 +713,9 @@ async fn recorded_answer_on_older_format_card_survives_the_refresh() { #[test] fn render_card_text_outside_the_fence_is_inert() { // Everything either app gives meaning to, an entity, a dropped character - // that would join "www.", and a trailing rule. + // that would join "www.", a custom emoji shortcode and a trailing rule. let active = "[a](https://x.example) ![i](u) @Honey #general |a|b| *em* _em_ `c` \ - ~~s~~ u \\(x\\) \\[y\\] $z$ #ch w[w]w.x.example ---"; + ~~s~~ u \\(x\\) \\[y\\] $z$ #ch w[w]w.x.example :party: ---"; let mut item = check(&[]).unwrap(); item.title = format!("# {active}"); item.options = ["- ", "> ", "1. ", "(x) "] @@ -734,7 +734,16 @@ fn render_card_text_outside_the_fence_is_inert() { for (i, n) in ["1. ", "2. ", "3. ", "4. "].iter().enumerate() { assert!(lines[i + 1].starts_with(n), "{card}"); } - for line in lines.iter().filter(|l| !l.is_empty()) { + for (i, line) in lines.iter().enumerate().filter(|(_, l)| !l.is_empty()) { + // No `:name:` the phone resolves against the community palette. + let parts: Vec<&str> = line.split(':').collect(); + let shortcode = parts.len() > 2 + && parts[1..parts.len() - 1].iter().any(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }); + assert!(!shortcode, "{line:?}"); let text = line .strip_prefix("**") .and_then(|l| l.strip_suffix("**")) @@ -748,6 +757,14 @@ fn render_card_text_outside_the_fence_is_inert() { "{text:?}" ); assert!(!text.contains("www.") && !text.ends_with('-'), "{text:?}"); + // The title and the options are plain_text alone. + if i < 5 { + let glued = text + .as_bytes() + .windows(2) + .any(|w| w[0] == b':' && w[1] != b' '); + assert!(!glued, "{text:?}"); + } // No nested list, quote, heading or radio button either. assert!( text.starts_with(|c: char| c.is_ascii_alphanumeric()), @@ -761,3 +778,70 @@ fn render_card_text_outside_the_fence_is_inert() { } } } + +#[test] +fn render_card_summary_never_breaks_the_fence() { + // The phone ends a block at the first line of exactly three backticks, + // so a longer fence never shielded what came after an inner one. + for body in [ + "x\n```\n[a](https://e.example)\n```", + "Run this first:\n```bash\ncargo test\n```\nthen pick.", + "inline ```x``` and ````y````", + ] { + let card = render_card(&check(&[("body", json!(body))]).unwrap(), "14:05"); + let lines: Vec<&str> = card.lines().collect(); + let fences: Vec = (0..lines.len()) + .filter(|&i| lines[i].starts_with("```")) + .collect(); + assert_eq!(fences.len(), 2, "{card}"); + assert_eq!((lines[fences[0]], lines[fences[1]]), ("```text", "```")); + assert_eq!(lines[fences[1] + 1], "1. Stay", "{card}"); + assert!(lines[fences[0] + 1..fences[1]] + .iter() + .all(|l| !l.contains('`'))); + } +} + +#[test] +fn render_card_wraps_the_summary_for_the_phone() { + let body = "Block's hosted relay rejects the new event kinds, so Bench-in-Buzz \ + cannot ship there. Stay and wait for upstream, or move the community \ + to our own relay?\n\nSee\tthe plan: a_na\u{EF}ve_identifier_longer_than_one_row."; + let card = render_card(&check(&[("body", json!(body))]).unwrap(), "14:05"); + let (_, rest) = card.split_once("```text\n").unwrap(); + let (inner, _) = rest.split_once("\n```\n").unwrap(); + assert!( + inner.lines().all(|l| l.chars().count() <= FENCE_WIDTH), + "{inner}" + ); + // Only the extra whitespace goes; the blank line stays. + let squash = |s: &str| s.split_whitespace().collect::(); + assert_eq!(squash(inner), squash(body)); + assert!(inner.contains("\n\n"), "{inner}"); + assert_eq!(wrap("aaa bbb ccc", 7), "aaa bbb\nccc"); + assert_eq!(wrap("a b\n\n\t c ", 26), "a b\n\nc"); + assert_eq!( + wrap(&"x".repeat(30), 26), + format!("{}\nxxxx", "x".repeat(26)) + ); +} + +#[test] +fn plain_text_folds_typography_and_spaces_colons() { + for (raw, plain) in [ + ( + "Don\u{2019}t merge \u{2014} wait for CI", + "Don't merge - wait for CI", + ), + ("Merge \u{201C}now\u{201D}", "Merge \"now\""), + ( + "it\u{02BC}s \u{2018}a\u{2019} \u{2013} b\u{2026}", + "it's 'a' - b...", + ), + ("\u{2014} later \u{2014}", "later"), + ("Ship it :party:", "Ship it : party:"), + ("Note: at 14:05", "Note: at 14: 05"), + ] { + assert_eq!(plain_text(raw, usize::MAX), plain, "{raw:?}"); + } +} From 63dc4b434e88577138407f71f9962031393ba548 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 08:33:21 -0500 Subject: [PATCH 11/28] feat(desktop): Bench screen with a pending badge A Bench entry in the sidebar's primary menu, under Inbox, opens a new /bench route. The entry shows only while bench_cards says the Bench is enabled. Its badge (the Inbox count pill, now shared as SIDEBAR_COUNT_BADGE_CLASS) counts the items that need the owner, refreshed every 15 s by its own query and faster while the screen or #bench polls. The screen follows the Agents/Workflows layout (PageHeader, SectionHeader) and reuses BenchCard. Sections follow the pinned board's order and sorting: Needs you (pending, current, unanswered, by severity, order, id), Answered, waiting for action (oldest answer first), Decided and Where things stand (by area, order, id). Each section has empty-state copy. The page shows the Bench's last update time, a visible error line when lastError is set, and a note counting stale or superseded items, which the board also folds. Cards answer through bench_answer with the Bench's own relay and keys, so the screen works in any community; only the owner's identity gets live buttons. benchCardMode now says "Waiting for the Bench to post this card" for an unanswered item with no live card, which only the screen can show. Its answered counterpart keeps "no longer live". Tests: pure section/badge tests, render tests of the real screen and sidebar entry against mocked IPC, a deriveShellRoute case, and a smoke spec (bench-screen.spec.ts) that opens the screen from the sidebar, answers a card, checks the bench_answer payload, then swaps the mock snapshot and sees the card recorded under Answered. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.helpers.test.mjs | 9 + desktop/src/app/AppShell.helpers.ts | 10 +- desktop/src/app/AppShell.tsx | 2 + .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 + desktop/src/app/routes.ts | 1 + desktop/src/app/routes/bench.tsx | 8 + .../features/bench/lib/benchCards.test.mjs | 8 +- desktop/src/features/bench/lib/benchCards.ts | 10 +- .../features/bench/lib/benchScreen.test.mjs | 152 +++++++ desktop/src/features/bench/lib/benchScreen.ts | 131 ++++++ .../features/bench/ui/BenchScreen.test.mjs | 410 ++++++++++++++++++ desktop/src/features/bench/ui/BenchScreen.tsx | 183 ++++++++ .../features/bench/ui/BenchSidebarEntry.tsx | 48 ++ desktop/src/features/bench/useBenchCards.ts | 18 + .../src/features/sidebar/ui/AppSidebar.tsx | 2 + .../features/sidebar/ui/AppSidebar.types.ts | 4 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 13 +- .../sidebar/ui/sidebarSectionStyles.ts | 4 + desktop/tests/e2e/bench-screen.spec.ts | 115 +++++ desktop/tests/helpers/bridge.ts | 7 + 22 files changed, 1163 insertions(+), 6 deletions(-) create mode 100644 desktop/src/app/routes/bench.tsx create mode 100644 desktop/src/features/bench/lib/benchScreen.test.mjs create mode 100644 desktop/src/features/bench/lib/benchScreen.ts create mode 100644 desktop/src/features/bench/ui/BenchScreen.test.mjs create mode 100644 desktop/src/features/bench/ui/BenchScreen.tsx create mode 100644 desktop/src/features/bench/ui/BenchSidebarEntry.tsx create mode 100644 desktop/tests/e2e/bench-screen.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 1af27b58ef2..a6419f242d2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", "**/bestie.spec.ts", + "**/bench-screen.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/message-copy-link.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index fc4a14f2cd7..7116426882b 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -5,9 +5,18 @@ import { markAllReadSources, activateDesktopNotificationTarget, createDesktopNotificationActivationQueue, + deriveShellRoute, shouldBounceForChannelNotification, } from "./AppShell.helpers.ts"; +test("deriveShellRoute_givesTheBenchItsOwnSidebarView", () => { + assert.deepEqual(deriveShellRoute("/bench"), { + selectedChannelId: null, + selectedView: "bench", + }); + assert.equal(deriveShellRoute("/bench/extra").selectedView, "home"); +}); + test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); }); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7c..91917758d67 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "bench"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -261,6 +262,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/bench") { + return { + selectedChannelId: null, + selectedView: "bench", + }; + } + return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 071cc3b1803..4cc1f4c0f1e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -146,6 +146,7 @@ export function AppShell() { useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot const { goAgents, + goBench, goChannel, goHome, goNewMessage, @@ -880,6 +881,7 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectBench={() => void goBench()} onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ade8c9332c0..f3aa4f50c09 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -104,6 +104,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goBench = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/bench", + }, + behavior, + ), + [commitNavigation], + ); + const goProfile = React.useCallback( (pubkey: string, behavior?: NavigationBehavior) => commitNavigation( @@ -459,6 +470,7 @@ export function useAppNavigation() { closeSettings, closeWorkflowDetail, goAgents, + goBench, goChannel, goDuplicateWorkflow, goEditWorkflow, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..e081606876a 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as benchRouteImport } from "./routes/bench"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const benchRoute = benchRouteImport.update({ + id: "/bench", + path: "/bench", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/bench": typeof benchRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/bench": typeof benchRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/bench": typeof benchRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/bench" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/bench" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/bench" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + benchRoute: typeof benchRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/bench": { + id: "/bench"; + path: "/bench"; + fullPath: "/bench"; + preLoaderRoute: typeof benchRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + benchRoute: benchRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..7e425bc6277 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), + route("/bench", "bench.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), route("/workflows", "workflows.tsx"), diff --git a/desktop/src/app/routes/bench.tsx b/desktop/src/app/routes/bench.tsx new file mode 100644 index 00000000000..0ad2c7cf444 --- /dev/null +++ b/desktop/src/app/routes/bench.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BenchScreen } from "@/features/bench/ui/BenchScreen"; + +// Not lazy: BenchCard already ships in the main chunk with the timeline. +export const Route = createFileRoute("/bench")({ + component: BenchScreen, +}); diff --git a/desktop/src/features/bench/lib/benchCards.test.mjs b/desktop/src/features/bench/lib/benchCards.test.mjs index 9636a8d28f1..7448ba40452 100644 --- a/desktop/src/features/bench/lib/benchCards.test.mjs +++ b/desktop/src/features/bench/lib/benchCards.test.mjs @@ -192,7 +192,13 @@ test("mode: read-only for another viewer, no owner, stale or superseded", () => [card(), null, OWNER, /no owner/], [card({ validity: "superseded" }), OWNER, OWNER, /Superseded/], [card({ validity: "suspected-stale" }), OWNER, OWNER, /May be stale/], - [card({ cardEventId: null }), OWNER, OWNER, /no longer live/], + [ + card({ cardEventId: null, answer: { option: 0, answeredAt: 1 } }), + OWNER, + OWNER, + /no longer live/, + ], + [card({ cardEventId: null }), OWNER, OWNER, /Waiting for the Bench/], ]; for (const [c, owner, viewer, reason] of cases) { const mode = benchCardMode(c, owner, viewer); diff --git a/desktop/src/features/bench/lib/benchCards.ts b/desktop/src/features/bench/lib/benchCards.ts index ed37d6ab7bb..85d4d4a44b2 100644 --- a/desktop/src/features/bench/lib/benchCards.ts +++ b/desktop/src/features/bench/lib/benchCards.ts @@ -183,7 +183,15 @@ export function benchCardMode( interactive: false, readOnlyReason, }); - if (!card.cardEventId) return readOnly("This card is no longer live."); + // Only the Bench screen shows an item without a live card: an answered one + // whose card was retired, or one the Bench has yet to (re)post. + if (!card.cardEventId) { + return readOnly( + card.answer + ? "This card is no longer live." + : "Waiting for the Bench to post this card in #bench.", + ); + } if (card.validity === "superseded") { return readOnly("Superseded: this card is being retired."); } diff --git a/desktop/src/features/bench/lib/benchScreen.test.mjs b/desktop/src/features/bench/lib/benchScreen.test.mjs new file mode 100644 index 00000000000..8a9e3f9a563 --- /dev/null +++ b/desktop/src/features/bench/lib/benchScreen.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + benchLastTickSeconds, + benchNeedsYouCount, + benchSections, + benchSeverityRank, +} from "./benchScreen.ts"; + +function card(id, overrides = {}) { + return { + id, + writer: "dreamforge", + kind: "pending", + title: id, + body: "", + area: "release", + severity: "attention", + options: ["Ship it", "Hold"], + state: null, + decidedBy: null, + deadline: null, + links: [], + order: 0, + validity: "current", + cardEventId: "e".repeat(64), + answer: null, + recorded: null, + ...overrides, + }; +} + +function snapshot(items) { + return { + enabled: true, + channelId: "8f6c1a52-3f0b-4f5e-9a51-0e2f5c7d9b10", + writerPubkey: "a".repeat(64), + ownerPubkey: "b".repeat(64), + lastError: null, + lastTick: "2026-10-12T10:00:00Z", + items, + }; +} + +const ids = (cards) => cards.map((c) => c.id); + +test("sections: each item lands in exactly one section, like the board", () => { + const sections = benchSections( + snapshot([ + card("w/open"), + card("w/answered", { answer: { option: 1, answeredAt: 50 } }), + card("w/decided", { kind: "decision", options: [], decidedBy: "claude" }), + card("w/status", { kind: "status", options: [], state: "done" }), + card("w/stale", { validity: "suspected-stale" }), + card("w/gone", { kind: "status", validity: "superseded" }), + ]), + ); + assert.deepEqual(ids(sections.needsYou), ["w/open"]); + assert.deepEqual(ids(sections.answered), ["w/answered"]); + assert.deepEqual(ids(sections.decided), ["w/decided"]); + assert.deepEqual(ids(sections.status), ["w/status"]); + assert.equal(sections.retiring, 2, "stale and superseded are folded"); +}); + +test("sections: needs you sorts by severity, then order, then id", () => { + const sections = benchSections( + snapshot([ + card("w/none", { severity: null }), + card("w/info", { severity: "info" }), + card("w/attention-late", { severity: "attention", order: 2 }), + card("w/critical", { severity: "critical", order: 9 }), + card("w/attention-b", { severity: "attention", order: 1 }), + card("w/attention-a", { severity: "attention", order: 1 }), + card("w/good", { severity: "good" }), + ]), + ); + assert.deepEqual(ids(sections.needsYou), [ + "w/critical", + "w/attention-a", + "w/attention-b", + "w/attention-late", + "w/info", + "w/good", + "w/none", + ]); +}); + +test("sections: answered is oldest answer first; decided and status by area", () => { + const sections = benchSections( + snapshot([ + card("w/later", { answer: { option: 0, answeredAt: 200 } }), + card("w/earlier", { answer: { option: 1, answeredAt: 100 } }), + card("w/d-ops", { kind: "decision", area: "ops", options: [] }), + card("w/d-app-2", { kind: "decision", area: "app", order: 2 }), + card("w/d-app-1", { kind: "decision", area: "app", order: 1 }), + card("w/s-web", { kind: "status", area: "web", options: [] }), + card("w/s-api", { kind: "status", area: "api", options: [] }), + ]), + ); + assert.deepEqual(ids(sections.answered), ["w/earlier", "w/later"]); + assert.deepEqual(ids(sections.decided), [ + "w/d-app-1", + "w/d-app-2", + "w/d-ops", + ]); + assert.deepEqual(ids(sections.status), ["w/s-api", "w/s-web"]); +}); + +test("sections: no snapshot is four empty sections", () => { + for (const value of [null, undefined]) { + const sections = benchSections(value); + assert.deepEqual( + [sections.needsYou, sections.answered, sections.decided, sections.status], + [[], [], [], []], + ); + assert.equal(sections.retiring, 0); + } +}); + +test("severity rank: unknown and missing severities sort last", () => { + assert.deepEqual( + ["critical", "attention", "info", "good", null, "toString"].map( + benchSeverityRank, + ), + [0, 1, 2, 3, 4, 4], + ); +}); + +test("badge: null while disabled, else the Needs you count", () => { + assert.equal(benchNeedsYouCount({ enabled: false }), null); + assert.equal(benchNeedsYouCount(undefined), null); + assert.equal(benchNeedsYouCount(snapshot([])), 0); + assert.equal( + benchNeedsYouCount( + snapshot([ + card("w/a"), + card("w/b", { severity: "critical" }), + card("w/answered", { answer: { option: 0, answeredAt: 1 } }), + card("w/stale", { validity: "superseded" }), + card("w/status", { kind: "status", options: [] }), + ]), + ), + 2, + ); +}); + +test("last tick: RFC 3339 to unix seconds; empty or garbage is null", () => { + assert.equal(benchLastTickSeconds("2026-10-12T10:00:00Z"), 1_791_799_200); + assert.equal(benchLastTickSeconds(""), null); + assert.equal(benchLastTickSeconds("not a time"), null); +}); diff --git a/desktop/src/features/bench/lib/benchScreen.ts b/desktop/src/features/bench/lib/benchScreen.ts new file mode 100644 index 00000000000..36c96e71697 --- /dev/null +++ b/desktop/src/features/bench/lib/benchScreen.ts @@ -0,0 +1,131 @@ +/** + * Pure logic for the Bench screen and its sidebar badge: which section each + * item belongs in, in the order the pinned #bench board uses (item.rs + * render_board), and how many items need the owner. + */ + +import { + type BenchCard, + type BenchCards, + type BenchSeverity, + type BenchSnapshot, + enabledBenchSnapshot, +} from "@/features/bench/lib/benchCards"; + +export type BenchSections = { + /** Pending, current and unanswered: by severity, then order. */ + needsYou: BenchCard[]; + /** Pending and current with an answer: oldest answer first. */ + answered: BenchCard[]; + /** Current decisions: by area, then order. */ + decided: BenchCard[]; + /** Current status items: by area, then order. */ + status: BenchCard[]; + /** Stale or superseded items. The board folds them; so does the screen. */ + retiring: number; +}; + +const SEVERITY_RANK: Record = { + critical: 0, + attention: 1, + info: 2, + good: 3, +}; +const UNRATED_RANK = 4; + +/** item.rs severity_rank: critical first, no severity last. */ +export function benchSeverityRank(severity: string | null): number { + return severity && Object.hasOwn(SEVERITY_RANK, severity) + ? SEVERITY_RANK[severity as BenchSeverity] + : UNRATED_RANK; +} + +/** Code-unit order, like Rust's String ordering for the ASCII ids. */ +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function byKeys(...keys: Array<(card: BenchCard) => number | string>) { + return (a: BenchCard, b: BenchCard) => { + for (const key of keys) { + const x = key(a); + const y = key(b); + const order = + typeof x === "number" && typeof y === "number" + ? x - y + : compareText(String(x), String(y)); + if (order !== 0) return order; + } + return 0; + }; +} + +const byNeed = byKeys( + (card) => benchSeverityRank(card.severity), + (card) => card.order, + (card) => card.id, +); +const byAnswerTime = byKeys( + (card) => card.answer?.answeredAt ?? 0, + (card) => card.id, +); +const byArea = byKeys( + (card) => card.area, + (card) => card.order, + (card) => card.id, +); + +const EMPTY_SECTIONS: BenchSections = { + needsYou: [], + answered: [], + decided: [], + status: [], + retiring: 0, +}; + +export function benchSections( + snapshot: BenchSnapshot | null | undefined, +): BenchSections { + if (!snapshot) return EMPTY_SECTIONS; + const sections: BenchSections = { + needsYou: [], + answered: [], + decided: [], + status: [], + retiring: 0, + }; + for (const card of snapshot.items) { + if (card.validity !== "current") { + sections.retiring += 1; + } else if (card.kind === "pending") { + (card.answer ? sections.answered : sections.needsYou).push(card); + } else if (card.kind === "decision") { + sections.decided.push(card); + } else if (card.kind === "status") { + sections.status.push(card); + } + } + sections.needsYou.sort(byNeed); + sections.answered.sort(byAnswerTime); + sections.decided.sort(byArea); + sections.status.sort(byArea); + return sections; +} + +/** + * The sidebar badge: null while the Bench is not enabled, otherwise the size + * of the screen's "Needs you" section (the board's "needs you" count). + */ +export function benchNeedsYouCount( + cards: BenchCards | null | undefined, +): number | null { + const snapshot = enabledBenchSnapshot(cards); + return snapshot ? benchSections(snapshot).needsYou.length : null; +} + +/** The Bench's last tick (RFC 3339) in unix seconds; null before the first. */ +export function benchLastTickSeconds(lastTick: string): number | null { + if (!lastTick) return null; + const ms = Date.parse(lastTick); + return Number.isFinite(ms) ? Math.floor(ms / 1_000) : null; +} diff --git a/desktop/src/features/bench/ui/BenchScreen.test.mjs b/desktop/src/features/bench/ui/BenchScreen.test.mjs new file mode 100644 index 00000000000..ce7c219340e --- /dev/null +++ b/desktop/src/features/bench/ui/BenchScreen.test.mjs @@ -0,0 +1,410 @@ +/** + * Production-seam pins for the Bench screen and its sidebar entry: the real + * `BenchScreen` and `BenchSidebarEntry` mounted against a mocked Tauri IPC + * boundary. + * + * - The screen shows four sections in the board's order, each with its own + * empty copy, plus the last update time and a visible lastError line. + * - Answering from the screen sends `bench_answer` and the card moves to + * "Answered, waiting for action" once the snapshot records it. + * - Only the owner's identity gets live option buttons. + * - The sidebar entry exists only while the Bench is enabled; its badge is + * the "Needs you" count. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const clients = []; +const ipcCalls = []; +const ipcHandlers = new Map(); + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let waitFor; +let within; +let createElement; +let QueryClient; +let QueryClientProvider; +let TooltipProvider; +let SidebarProvider; +let SidebarMenu; +let BenchScreen; +let BenchSidebarEntry; + +const OWNER = "b".repeat(64); +const STRANGER = "c".repeat(64); + +function card(id, overrides = {}) { + return { + id, + writer: "dreamforge", + kind: "pending", + title: `Title of ${id}`, + body: "", + area: "release", + severity: "attention", + options: ["Ship it", "Hold"], + state: null, + decidedBy: null, + deadline: null, + links: [], + order: 0, + validity: "current", + cardEventId: "e".repeat(64), + answer: null, + recorded: null, + ...overrides, + }; +} + +function snapshot(items, overrides = {}) { + return { + enabled: true, + channelId: "8f6c1a52-3f0b-4f5e-9a51-0e2f5c7d9b10", + writerPubkey: "a".repeat(64), + ownerPubkey: OWNER, + lastError: null, + lastTick: new Date().toISOString(), + items, + ...overrides, + }; +} + +function serveSnapshot(value) { + ipcHandlers.set("bench_cards", () => Promise.resolve(value)); +} + +function serveIdentity(pubkey) { + ipcHandlers.set("get_identity", () => + Promise.resolve({ pubkey, display_name: "me", lost: false, locked: false }), + ); +} + +function wrap(element) { + const client = new QueryClient({ + defaultOptions: { + mutations: { gcTime: 0 }, + queries: { gcTime: 0, retry: false }, + }, + }); + clients.push(client); + return createElement( + QueryClientProvider, + { client }, + createElement(TooltipProvider, null, element), + ); +} + +function cardIds(container) { + return [...container.querySelectorAll("[data-testid='bench-card']")].map( + (node) => node.getAttribute("data-bench-item"), + ); +} + +function answerCalls() { + return ipcCalls.filter((c) => c.cmd === "bench_answer").map((c) => c.args); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key === "window" || key === "document" || key === "globalThis") + continue; + const value = dom.window[key]; + if ( + typeof value === "function" && + /^(HTML|SVG)|Element$|Event$|EventTarget$|^Node|^Document|Observer$/.test( + key, + ) + ) { + globalThis[key] = value; + } + } + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + ipcCalls.push({ cmd, args }); + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen, waitFor, within } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip")); + ({ SidebarMenu, SidebarProvider } = await import("@/shared/ui/sidebar")); + ({ BenchScreen } = await import("./BenchScreen.tsx")); + ({ BenchSidebarEntry } = await import("./BenchSidebarEntry.tsx")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + ipcHandlers.clear(); + ipcCalls.length = 0; +}); + +after(() => dom.window.close()); + +test("screen: board-order sections, counts, empty copy and the error line", async () => { + serveIdentity(OWNER); + serveSnapshot( + snapshot( + [ + card("w/later", { severity: "info" }), + card("w/first", { severity: "critical" }), + card("w/answered", { + answer: { option: 1, answeredAt: 1 }, + recorded: 1, + }), + card("w/status", { kind: "status", options: [], state: "blocked" }), + card("w/stale", { validity: "superseded" }), + ], + { lastError: "relay timed out" }, + ), + ); + let view; + await act(async () => { + view = render(wrap(createElement(BenchScreen))); + }); + await waitFor(() => screen.getByTestId("bench-section-needsYou")); + + assert.deepEqual( + [...view.container.querySelectorAll("section")].map((s) => + s.getAttribute("aria-label"), + ), + [ + "Needs you", + "Answered, waiting for action", + "Decided", + "Where things stand", + ], + ); + const needsYou = screen.getByTestId("bench-section-needsYou"); + assert.deepEqual(cardIds(needsYou), ["w/first", "w/later"]); + assert.ok(within(needsYou).getByRole("heading", { name: "Needs you 2" })); + assert.deepEqual(cardIds(screen.getByTestId("bench-section-answered")), [ + "w/answered", + ]); + assert.deepEqual(cardIds(screen.getByTestId("bench-section-status")), [ + "w/status", + ]); + const decided = screen.getByTestId("bench-section-decided"); + assert.deepEqual(cardIds(decided), []); + assert.ok(within(decided).getByText("No decisions yet.")); + assert.equal( + screen.getByTestId("bench-last-error").textContent, + "The Bench's last update failed: relay timed out", + ); + assert.match( + screen.getByTestId("bench-last-update").textContent, + /^Last update: \d{1,2}:\d{2}\s[AP]M$/, + ); + assert.ok( + screen.getByText( + "1 stale or superseded item is being retired and isn't shown.", + ), + ); +}); + +test("screen: an answer goes through bench_answer and the card moves to Answered", async () => { + serveIdentity(OWNER); + const open = card("w/ship"); + serveSnapshot(snapshot([open])); + ipcHandlers.set("bench_answer", ({ itemId, option }) => { + // The Bench's tick records the keycap; the next snapshot shows it. + serveSnapshot( + snapshot([ + { ...open, recorded: option, answer: { option, answeredAt: 1 } }, + ]), + ); + assert.equal(itemId, "w/ship"); + return Promise.resolve(null); + }); + await act(async () => { + render(wrap(createElement(BenchScreen))); + }); + const needsYou = await waitFor(() => + screen.getByTestId("bench-section-needsYou"), + ); + await waitFor(() => assert.deepEqual(cardIds(needsYou), ["w/ship"])); + + await act(async () => { + fireEvent.click( + within(needsYou).getByRole("button", { name: /^2\. Hold/ }), + ); + }); + assert.deepEqual(answerCalls(), [{ itemId: "w/ship", option: 1 }]); + + const answered = screen.getByTestId("bench-section-answered"); + await waitFor(() => assert.deepEqual(cardIds(answered), ["w/ship"])); + assert.deepEqual(cardIds(screen.getByTestId("bench-section-needsYou")), []); + assert.ok(screen.getByText("Nothing needs you right now.")); + assert.match( + within(answered).getByTestId("bench-card-recorded").textContent, + /^Recorded: 2\. Hold/, + ); +}); + +test("screen: only the owner's identity gets live buttons", async () => { + serveIdentity(STRANGER); + serveSnapshot(snapshot([card("w/ship")])); + await act(async () => { + render(wrap(createElement(BenchScreen))); + }); + await waitFor(() => + assert.ok(screen.getByText("Only the Bench owner can answer this card.")), + ); + const button = screen.getByRole("button", { name: /^1\. Ship it/ }); + assert.equal(button.disabled, true); + await act(async () => { + fireEvent.click(button); + }); + assert.equal(answerCalls().length, 0); +}); + +test("screen: disabled, unreadable and not-yet-ticked Benches say so", async () => { + serveIdentity(OWNER); + serveSnapshot({ enabled: false }); + await act(async () => { + render(wrap(createElement(BenchScreen))); + }); + await waitFor(() => + assert.equal( + screen.getByTestId("bench-disabled").textContent, + "The Bench isn't enabled on this computer.", + ), + ); + assert.equal(screen.queryByTestId("bench-section-needsYou"), null); + cleanup(); + + ipcHandlers.set("bench_cards", () => Promise.reject("no home directory")); + await act(async () => { + render(wrap(createElement(BenchScreen))); + }); + await waitFor(() => + assert.ok(screen.getByText("Couldn't read the Bench: no home directory")), + ); + cleanup(); + + serveSnapshot(snapshot([], { channelId: "", lastTick: "" })); + await act(async () => { + render(wrap(createElement(BenchScreen))); + }); + await waitFor(() => + assert.equal( + screen.getByTestId("bench-last-update").textContent, + "Waiting for the Bench's first update.", + ), + ); + for (const copy of [ + "Nothing needs you right now.", + "Nothing you answered is waiting on an agent.", + "No decisions yet.", + "No status updates yet.", + ]) { + assert.ok(screen.getByText(copy), copy); + } +}); + +function sidebarEntry(onSelect) { + return wrap( + createElement( + SidebarProvider, + null, + createElement( + SidebarMenu, + null, + createElement(BenchSidebarEntry, { isActive: false, onSelect }), + ), + ), + ); +} + +test("sidebar: hidden while disabled; the badge counts Needs you", async () => { + serveSnapshot({ enabled: false }); + await act(async () => { + render(sidebarEntry(() => {})); + }); + await waitFor(() => assert.ok(ipcCalls.some((c) => c.cmd === "bench_cards"))); + // Let the disabled snapshot land before asserting nothing rendered. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + assert.equal(screen.queryByTestId("open-bench-view"), null); + cleanup(); + + serveSnapshot( + snapshot([ + card("w/a"), + card("w/b", { severity: "critical" }), + card("w/answered", { answer: { option: 0, answeredAt: 1 } }), + card("w/stale", { validity: "suspected-stale" }), + ]), + ); + let selected = 0; + await act(async () => { + render( + sidebarEntry(() => { + selected += 1; + }), + ); + }); + const button = await waitFor(() => screen.getByTestId("open-bench-view")); + assert.equal(screen.getByTestId("sidebar-bench-count").textContent, "2"); + await act(async () => { + fireEvent.click(button); + }); + assert.equal(selected, 1); + cleanup(); + + serveSnapshot(snapshot([card("w/done", { kind: "status", options: [] })])); + await act(async () => { + render(sidebarEntry(() => {})); + }); + await waitFor(() => screen.getByTestId("open-bench-view")); + assert.equal( + screen.queryByTestId("sidebar-bench-count"), + null, + "no badge when nothing needs the owner", + ); +}); diff --git a/desktop/src/features/bench/ui/BenchScreen.tsx b/desktop/src/features/bench/ui/BenchScreen.tsx new file mode 100644 index 00000000000..6740768a7bc --- /dev/null +++ b/desktop/src/features/bench/ui/BenchScreen.tsx @@ -0,0 +1,183 @@ +import * as React from "react"; + +import type { BenchCard as BenchCardData } from "@/features/bench/lib/benchCards"; +import { + type BenchSections, + benchLastTickSeconds, + benchSections, +} from "@/features/bench/lib/benchScreen"; +import { BenchCard } from "@/features/bench/ui/BenchCard"; +import { useBenchCards } from "@/features/bench/useBenchCards"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; +import { PageHeader, SectionHeader } from "@/shared/ui/PageHeader"; + +type SectionKey = Exclude; + +const SECTIONS: ReadonlyArray<{ + key: SectionKey; + title: string; + empty: string; +}> = [ + { + key: "needsYou", + title: "Needs you", + empty: "Nothing needs you right now.", + }, + { + key: "answered", + title: "Answered, waiting for action", + empty: "Nothing you answered is waiting on an agent.", + }, + { key: "decided", title: "Decided", empty: "No decisions yet." }, + { + key: "status", + title: "Where things stand", + empty: "No status updates yet.", + }, +]; + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * The Bench as a full screen: what needs the owner, what is answered and + * waiting on an agent, what was decided, and where things stand. Cards answer + * through `bench_answer`, which uses the Bench's own relay and keys, so the + * screen works whichever community is active. + */ +export function BenchScreen() { + const { snapshot, error, isPending } = useBenchCards(); + const viewerPubkey = useIdentityQuery().data?.pubkey; + const sections = React.useMemo(() => benchSections(snapshot), [snapshot]); + + let content: React.ReactNode; + if (!snapshot) { + content = isPending ? ( +

Loading the Bench...

+ ) : error ? ( +

+ Couldn't read the Bench: {errorText(error)} +

+ ) : ( +

+ The Bench isn't enabled on this computer. +

+ ); + } else { + const lastTick = benchLastTickSeconds(snapshot.lastTick); + content = ( + <> +
+

+ {lastTick === null + ? "Waiting for the Bench's first update." + : `Last update: ${formatItemTimestamp(lastTick, { withTime: true })}`} +

+ {snapshot.lastError ? ( +

+ The Bench's last update failed: {snapshot.lastError} +

+ ) : null} + {error ? ( +

+ Couldn't refresh the Bench: {errorText(error)} +

+ ) : null} +
+ {SECTIONS.map((section) => ( + + ))} + {sections.retiring > 0 ? ( +

+ {sections.retiring === 1 + ? "1 stale or superseded item is being retired and isn't shown." + : `${sections.retiring} stale or superseded items are being retired and aren't shown.`} +

+ ) : null} + + ); + } + + return ( +
+
+
+ + {content} +
+
+
+ ); +} + +function BenchSection({ + cards, + empty, + ownerPubkey, + testId, + title, + viewerPubkey, +}: { + cards: BenchCardData[]; + empty: string; + ownerPubkey: string | null; + testId: string; + title: string; + viewerPubkey: string | undefined; +}) { + return ( +
+ + {title}{" "} + + {cards.length} + + + } + /> + {cards.length === 0 ? ( +

{empty}

+ ) : ( +
    + {cards.map((card) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/features/bench/ui/BenchSidebarEntry.tsx b/desktop/src/features/bench/ui/BenchSidebarEntry.tsx new file mode 100644 index 00000000000..d6613e6dee9 --- /dev/null +++ b/desktop/src/features/bench/ui/BenchSidebarEntry.tsx @@ -0,0 +1,48 @@ +import { ListChecks } from "lucide-react"; + +import { useBenchNeedsYouCount } from "@/features/bench/useBenchCards"; +import { SIDEBAR_COUNT_BADGE_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, +} from "@/shared/ui/sidebar"; +import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; + +/** + * The Bench in the sidebar's primary menu, shown only while the Bench is + * enabled on this computer, with a badge for the items that need the owner. + */ +export function BenchSidebarEntry({ + isActive, + onSelect, +}: { + isActive: boolean; + onSelect: () => void; +}) { + const needsYou = useBenchNeedsYouCount(); + if (needsYou === null) return null; + return ( + + + + Bench + + {needsYou > 0 ? ( + + {Math.min(needsYou, 99)} + + ) : null} + + ); +} diff --git a/desktop/src/features/bench/useBenchCards.ts b/desktop/src/features/bench/useBenchCards.ts index 40e1bed3e1b..b657b758c0d 100644 --- a/desktop/src/features/bench/useBenchCards.ts +++ b/desktop/src/features/bench/useBenchCards.ts @@ -17,6 +17,7 @@ import { indexBenchCards, isBenchCandidate, } from "@/features/bench/lib/benchCards"; +import { benchNeedsYouCount } from "@/features/bench/lib/benchScreen"; import { invokeTauri } from "@/shared/api/tauri"; import { isDocumentVisible } from "@/shared/lib/useDocumentVisible"; @@ -25,6 +26,8 @@ export const BENCH_CARDS_QUERY_KEY = ["bench-cards"] as const; export const BENCH_POLL_INTERVAL_MS = 3_000; /** Off #bench the snapshot only gates rendering, so a mount reuses it. */ const BENCH_IDLE_STALE_MS = 60_000; +/** The sidebar badge's own cadence, everywhere else in the app. */ +const BENCH_SIDEBAR_POLL_MS = 15_000; export type BenchAnswerInput = { itemId: string; @@ -103,6 +106,21 @@ export function useBenchAnswer() { }); } +/** + * The sidebar's Bench badge: null while the Bench is not enabled (the entry + * hides), else how many items need the owner. The sidebar is always mounted, + * so it refreshes on its own slow timer; the Bench screen's 3 s poll and the + * answer mutation refresh the same snapshot faster. + */ +export function useBenchNeedsYouCount(): number | null { + const { data } = useQuery({ + ...benchCardsQueryOptions, + select: benchNeedsYouCount, + refetchInterval: BENCH_SIDEBAR_POLL_MS, + }); + return data ?? null; +} + /** * The live Bench card a timeline message is, or null. Only rows in #bench * poll; every other row reads the cached snapshot and re-renders only if its diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 647ed632644..ddf93a7e1db 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -114,6 +114,7 @@ export function AppSidebar({ onRemoveCommunity, onCreateAgent, onSelectAgents, + onSelectBench, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -560,6 +561,7 @@ export function AppSidebar({ ; unreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; @@ -84,6 +85,7 @@ export type AppSidebarProps = { onRemoveCommunity: (id: string) => Promise; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectBench: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 660f7d9a8aa..d9ac398ba9f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,7 +1,9 @@ import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react"; +import { BenchSidebarEntry } from "@/features/bench/ui/BenchSidebarEntry"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection"; +import { SIDEBAR_COUNT_BADGE_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; import { FeatureGate } from "@/shared/features"; import type { Channel, SearchHit } from "@/shared/api/types"; import { @@ -21,7 +23,8 @@ type SidebarSelectedView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "bench"; type AppSidebarPinnedHeaderProps = { channelLabels: Record; @@ -42,6 +45,7 @@ type AppSidebarPinnedHeaderProps = { type AppSidebarPrimaryMenuProps = { homeBadgeCount: number; onSelectAgents: () => void; + onSelectBench: () => void; onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -92,6 +96,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, + onSelectBench, onSelectHome, onSelectProjects, onSelectPulse, @@ -120,13 +125,17 @@ export function AppSidebarPrimaryMenu({ {homeBadgeCount > 0 ? ( {Math.min(homeBadgeCount, 99)} ) : null} + { + await installMockBridge(page, { benchCards: benchSnapshot(null) }); + await page.goto("/"); + + const entry = page.getByTestId("open-bench-view"); + await expect(entry).toBeVisible(); + await expect(page.getByTestId("sidebar-bench-count")).toHaveText("1"); + await entry.click(); + await expect(page).toHaveURL(/#\/bench$/); + await expect(page.getByTestId("bench-screen")).toBeVisible(); + await expect( + page + .getByTestId("bench-section-status") + .getByText("Relay migration", { exact: true }), + ).toBeVisible(); + + const needsYou = page.getByTestId("bench-section-needsYou"); + const card = needsYou.getByTestId("bench-card"); + await expect(card).toContainText("Ship the 0.6 release?"); + await card.getByRole("button", { name: /^2\. Hold for mobile/ }).click(); + + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter((entry) => entry.command === "bench_answer") + .map((entry) => entry.payload), + ), + ) + .toEqual([{ itemId: ITEM_ID, option: 1 }]); + await expect(card.getByText("Saving...")).toBeVisible(); + + // The Bench's tick records the owner's keycap; the screen's 3 s poll + // picks up the new snapshot. + await page.evaluate((next) => { + const config = window.__BUZZ_E2E__; + if (config) config.mock = { ...config.mock, benchCards: next }; + }, benchSnapshot(1)); + + const answered = page.getByTestId("bench-section-answered"); + await expect(answered.getByTestId("bench-card-recorded")).toHaveText( + /^Recorded: 2\. Hold for mobile/, + { timeout: 10_000 }, + ); + await expect( + needsYou.getByText("Nothing needs you right now."), + ).toBeVisible(); + await expect(page.getByTestId("sidebar-bench-count")).toHaveCount(0); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..95cc6afa5f6 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; +import type { BenchCards } from "../../src/features/bench/lib/benchCards"; import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; @@ -467,6 +468,12 @@ type MockBridgeOptions = { scope_value: string; kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" }>; + /** + * The `bench_cards` snapshot; omitted means the Bench is not enabled. A + * spec can replace `window.__BUZZ_E2E__.mock.benchCards` mid-test to play + * the Bench's tick recording an answer. + */ + benchCards?: BenchCards; /** * Event IDs that `get_event` should report as definitively not found. * Causes `useDraftRootStatus` to map the draft to `deleted` state so specs From d716b9e8503effbc7bca59b1c38c578c9bd2d959 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 08:53:49 -0500 Subject: [PATCH 12/28] docs: a guest guide for people invited to a Dreamforge community Nothing in Buzz or Dreamforge explains the app to an invited guest. Add docs/guest-guide.md: Mac and iPhone first, then Windows and Android. It covers the basic words, public and private channels, getting each app, joining with an invite link, using a computer and a phone as the same member, seeing what a channel is for, asking an agent and what silence means, notifications, reporting a message and who to ask for help. Every UI step was checked against desktop/src at origin/main and mobile/lib at mobile-v0.20.0-rc.1; the header line and sidebar hover are marked as Dreamforge 0.5.22 and later. Link it from the top of the README, and point the Welcome canvas link (labelled Dreamforge user guide, opening Block's developer README) at the guide as Dreamforge guest guide. Existing Welcome canvases keep their saved text. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- README.md | 2 + .../onboarding/welcomeCanvas.test.mjs | 7 +- .../src/features/onboarding/welcomeCanvas.ts | 2 +- docs/guest-guide.md | 241 ++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 docs/guest-guide.md diff --git a/README.md b/README.md index 261823c5e3b..fdf475da40c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Using Dreamforge? Start with the [guest guide](docs/guest-guide.md). + > **This is Dreamforge**, a fork of [`block/buzz`](https://github.com/block/buzz). > > The product ships as Dreamforge and every screen says so. Everything below the diff --git a/desktop/src/features/onboarding/welcomeCanvas.test.mjs b/desktop/src/features/onboarding/welcomeCanvas.test.mjs index 9251a96bce8..7c4b56dbf20 100644 --- a/desktop/src/features/onboarding/welcomeCanvas.test.mjs +++ b/desktop/src/features/onboarding/welcomeCanvas.test.mjs @@ -13,7 +13,12 @@ test("welcome canvas covers purpose, agent use, a first challenge, and help", () assert.match(WELCOME_CANVAS_CONTENT, /quick challenge/i); assert.match( WELCOME_CANVAS_CONTENT, - new RegExp(`${PRODUCT_NAME} user guide`, "i"), + new RegExp(`${PRODUCT_NAME} guest guide`, "i"), + ); + assert.ok( + WELCOME_CANVAS_CONTENT.includes( + "(https://github.com/QuicksilverSlick/buzz/blob/main/docs/guest-guide.md)", + ), ); }); diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts index 0c84bbe02fa..b5c3474de04 100644 --- a/desktop/src/features/onboarding/welcomeCanvas.ts +++ b/desktop/src/features/onboarding/welcomeCanvas.ts @@ -16,7 +16,7 @@ Bring the team something you are building, or give them a quick challenge to see ## Get help -Ask the team a question here, or read the [Dreamforge user guide](https://github.com/block/buzz#readme). +Ask the team a question here, or read the [Dreamforge guest guide](https://github.com/QuicksilverSlick/buzz/blob/main/docs/guest-guide.md). `; type WelcomeCanvasClient = { diff --git a/docs/guest-guide.md b/docs/guest-guide.md new file mode 100644 index 00000000000..a430a655820 --- /dev/null +++ b/docs/guest-guide.md @@ -0,0 +1,241 @@ +# Dreamforge guest guide + +Someone invited you to their Dreamforge community. This guide shows you how to +get in, find your way around, and ask the community's agents for help. Each +part covers a Mac and an iPhone first, then Windows and Android. + +Stuck at any point? Ask the person who invited you. + +## The apps + +- **On a Mac or Windows PC**, the app is called Dreamforge. +- **On an iPhone or Android phone**, the app is called Buzz, made by Block. + There is no Dreamforge phone app. Dreamforge is built on Buzz, so the Buzz + app opens the same community. + +## Words you'll see + +- **Community**: the shared space you were invited to. You get in with an + invite link. +- **Channel**: a room for one topic, such as #general. Everyone in the channel + can read and write there. +- **Direct message**: a private conversation between you and one or a few + people. On a Mac or PC they're under **Direct messages** in the sidebar. On a + phone, tap **+** at the bottom of Home, then **New direct message**. +- **Thread**: replies attached to one message, so a side conversation doesn't + fill up the channel. +- **Agent**: an AI helper that someone in the community set up. Agents have a + rounded-square picture (people have round ones), and the word "agent" appears + next to their name when you type @. +- **Identity key**: your account. It's a long secret code that the app keeps on + your device, so there's no email or password to remember. + +## Public and private channels + +- **Public channels**, also called open channels, are open to everyone in the + community. You can find and join them in **Browse channels**. +- **Private channels** have a lock icon instead of #. You only see a private + channel after someone adds you to it. If you expected a channel and can't + find it, ask the person who invited you to add you. + +## Get the app + +### Mac or Windows PC: Dreamforge + +1. Open the + [Dreamforge download page](https://github.com/QuicksilverSlick/buzz/releases/tag/dreamforge-desktop-latest). +2. Pick the row for your computer and click its file. Not sure which Mac you + have? Click the Apple menu, then **About This Mac**. "Chip: Apple" means the + Apple chip file. "Processor: Intel" means the Intel file. +3. On a Mac, open the downloaded file, drag **Dreamforge** into + **Applications**, and open it from there. The first time, your Mac says + Dreamforge was not opened. Click **Done**. Then open **System Settings**, + click **Privacy & Security**, scroll down to **Security**, and click + **Open Anyway** next to Dreamforge. Click **Open Anyway** again and enter + your Mac password. You only do this once. +4. On Windows, open the downloaded file. If Windows says "Windows protected + your PC", click **More info**, then **Run anyway**. + +The download page has the same steps in more detail. After this, Dreamforge +updates itself: when **Ready to update!** appears in its sidebar, click it. + +If your invite page offers its own download link, use the Dreamforge download +page instead. + +### iPhone or Android phone: Buzz + +Install **Buzz** by Block from the +[App Store](https://apps.apple.com/app/id6779728271) on an iPhone, or from +[Google Play](https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile) +on Android. + +## Join with your invite link + +Use your invite link on one device only. If you join with it on both your +computer and your phone, you become two separate members, and an agent set up +to answer you will only answer one of them. To use both, join on one and then +connect the other (see [Use both a computer and a phone](#use-both-a-computer-and-a-phone)). + +### On a Mac or Windows PC + +1. Open Dreamforge and click **Create a new identity key**, then **Next**. If + your Mac asks for your login password so Dreamforge can use the keychain, + type it. +2. On **Set up your agent harnesses** and on + **Configure your default model settings**, click **Skip for now**. Those + screens are for people who run their own agents. +3. Click **Join a community**, paste your invite link, and click **Next**. If + you're asked, confirm your age and agree to the terms. +4. Type the name people will see and click **Next**. On the next screen, + click **Take me to Dreamforge**. + +If you clicked **Accept invite** on the invite page before setting up, +Dreamforge remembers the invite and finishes joining when setup is done. + +Dreamforge also gives you a private channel called **Welcome** with three +helper agents: Fizz, Honey and Pollen. They run on your own AI account, so +without one they ask you to connect an AI provider. You don't need to. Your +community's channels are in the sidebar under **Channels**. + +### On an iPhone or Android phone + +1. Install Buzz first. If you open it before joining, it asks you to scan a + code from a desktop app. You don't need that to join; go back to your + invite link. +2. Tap your invite link. A web page opens that says you're invited. +3. If the page asks, confirm your age and agree to the terms. +4. Tap **Accept invite in Buzz** (some invite pages say + **Accept invite in Dreamforge**). Buzz opens and asks + **Join this Buzz community?** Tap **Join**. +5. Tap **Continue to #welcome-everyone** (or **Not now**). +6. To set your name, tap your picture in the top-right corner of Home, then + **Display name**. + +After you join, Buzz warns that this phone is the only copy of your account +and offers **Back it up now**. That can wait. It connects your phone to +Dreamforge on a computer, and the person who invited you can help with it +later. + +## Use both a computer and a phone + +Join on your Mac first, then connect your iPhone to it. Your phone then signs +in as you, in the same community. The same steps work with Windows and +Android. + +1. In Dreamforge, click your picture at the bottom of the sidebar, then + **Settings**. Choose **Mobile**, then click **Start pairing**. A square + code (a QR code) appears. +2. Open Buzz on your iPhone, tap **Scan a QR code**, and point the camera at + the code on your Mac. +3. Your Mac shows six digits. Type them on your iPhone. If your iPhone offers + to protect your account with Face ID, choose that or **Skip**. +4. On your Mac, click **Codes match**. + +Dreamforge may say "Dreamforge mobile app" on this screen. On your phone, +that's the Buzz app. + +Already joined on your phone? Don't use the invite link again on your +computer. Ask the person who invited you to help you connect it. + +## See what a channel is for + +### Mac or Windows PC + +- Open the channel. In Dreamforge 0.5.22 and later, the line under the + channel name at the top says what it's for. In earlier versions, rest the + pointer on the channel name at the top to see it. +- In Dreamforge 0.5.22 and later, rest the pointer on a channel in the sidebar + to see its name and description. +- To browse every public channel, point at **Channels** in the sidebar and + click **+**, or press Shift-Command-O (Shift-Ctrl-O on Windows). Each + channel shows its description. Click **Join** to join one. + +### iPhone or Android phone + +- Open the channel and tap its name at the top. The description appears under + the channel name. +- To see public channels you haven't joined, tap **+** at the bottom of Home, + then **Browse channels**. Each one shows the start of its description. Tap + **Join** to join one. + +Some channels have no description. If you're not sure what a channel is for, +ask in the channel or ask the person who invited you. + +## Ask an agent + +1. Open a channel the agent is in. +2. Type @ and the first letters of the agent's name, then pick it from the + list. Agents are marked "agent". +3. Write your request after its name and send it. For example: + "@Helper Can you sum up what was decided in this channel last week?" Use + the agent's real name. + +What happens next: + +- While the agent works, you'll usually see that it's typing. +- Its answer arrives as a reply to your message. Click or tap the reply count + under your message (for example, "1 reply") to read it. +- Click or tap an agent's picture to see its profile. If its owner wrote a + description, it says what the agent is for. + +Ask agents in a channel, not in a direct message. In a direct message, an +agent only answers the person who runs it. + +### If an agent doesn't answer + +Each agent only answers the people its owner chose. If you're not one of them, +the agent stays quiet: usually you won't see an error, just no reply. If you +see a note that an agent can't reply to you, it means the same thing. If a few +minutes pass with no answer, ask the person who invited you to let the agent +answer you. + +## Notifications + +### Mac or Windows PC + +Dreamforge alerts are on by default. To change them, click your picture at +the bottom of the sidebar, then **Settings**, then **Notifications**. If your +computer blocks them, turn on notifications for Dreamforge in your system +settings. On a Mac, closing the Dreamforge window keeps it running, so alerts +still arrive. Quitting it stops them. + +### iPhone + +Buzz notifications are off until you turn them on. + +1. Tap your picture in the top-right corner of Home to open Settings. +2. Under **Notifications**, turn on **Push notifications**. +3. When your iPhone asks, tap **Allow**. + +If Buzz says notifications are disabled in iOS Settings, tap +**Open iOS Notification Settings** and turn them on there. If you don't see +**Notifications** in Buzz's Settings, this community can't send phone +notifications yet. + +### Android + +Buzz on Android can't send notifications yet. Open the app now and then to +check for messages, and watch for texts or emails from the person who invited +you. + +## Report a message + +### Mac or Windows PC + +1. Point at the message and click the three-dot **More actions** button. +2. Click **Report message**. +3. Choose a reason, add a note if you like, and click **Submit report**. + +Reports go to the community's moderators. The author isn't told who reported +them. + +### iPhone or Android phone + +Buzz on phones doesn't have a report button yet. Touch and hold the message, +tap **Copy link**, and send the link to the person who invited you. You can +also report it from Dreamforge on a computer. + +## Get help + +Ask the person who invited you. They can add you to channels, let agents +answer you, and help with your account. From 548995dcabe96f79d5e0193b71d5ae8f782ba5d1 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 09:00:37 -0500 Subject: [PATCH 13/28] fix(bench): review: bench_answer follows latest wins across approvers; retired cards keep their answer bench_answer planned against the owner's reactions only, while the poll picks the newest keycap among the owner and every members.txt key. A phone-key tap that outranked the owner's keycap, or that a clear could not remove, made bench_answer publish nothing and return Ok, so the card sat on Saving... and then timed out on every retry. answer_card now queries the poll's approvers and runs pick_answer on what the plan leaves standing. An owner keycap outranked by a newer tap is deleted and then posted again: the relay refuses an active identical reaction as a duplicate, so the delete has to come first. A clear that another key's tap would survive is refused with "answered from another device; change or undo it there", before anything is published. The new keycap is stamped one second past the newest tap left standing, so a phone clock running ahead cannot keep outranking it. A tap more than MAX_CLOCK_LEAD_SECS ahead is refused rather than chased. On the Bench screen, an answered card that was retired after 24 h, or deleted by hand, lost its recorded option, because forget_card clears it and keeps the answer. Such a card showed no selection and no Recorded line. It now shows the Bench's answer, read-only. The reducer still settles on recorded alone. A selected but disabled option button no longer says "click to undo". Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/commands.rs | 70 ++++++++-- .../src/bench/tests/desktop_cards.rs | 128 +++++++++++++++--- .../features/bench/lib/benchCards.test.mjs | 37 +++++ desktop/src/features/bench/lib/benchCards.ts | 17 ++- desktop/src/features/bench/ui/BenchCard.tsx | 10 +- 5 files changed, 230 insertions(+), 32 deletions(-) diff --git a/desktop/src-tauri/src/bench/commands.rs b/desktop/src-tauri/src/bench/commands.rs index 4cdf6d156c7..30843f828d4 100644 --- a/desktop/src-tauri/src/bench/commands.rs +++ b/desktop/src-tauri/src/bench/commands.rs @@ -149,9 +149,15 @@ pub(crate) fn plan_answer( } } -/// bench_answer's relay half. Reads the owner's reactions on the live card -/// with the poll's own /query, then publishes the add before the deletes, so -/// a failed delete never loses the new answer (latest wins in pick_answer). +/// Refused clear: a tap by another approver key would still answer the card. +pub(crate) const ANSWERED_ELSEWHERE: &str = "answered from another device; change or undo it there"; + +/// bench_answer's relay half. Reads every approver's reactions on the live +/// card with the poll's own /query and plans against pick_answer's latest +/// wins: an owner keycap outranked by a newer tap from a members.txt key is +/// posted again, and a clear such a tap would survive is refused. The add +/// goes before the other deletes, so a failed delete never loses the new +/// answer. pub(crate) async fn answer_card( ctx: &Ctx<'_>, s: &BenchState, @@ -181,8 +187,12 @@ pub(crate) async fn answer_card( if s.owner_pubkey != owner.to_hex() { return Err("owner identity changed; refusing to answer".to_string()); } + // The poll's approvers: the owner plus the phone's own key in members.txt. + let approvers: Vec = std::iter::once(s.owner_pubkey.clone()) + .chain(super::read_members(&ctx.root)) + .collect(); let filter = serde_json::json!({ - "kinds": [7], "authors": [s.owner_pubkey], "#h": [s.channel_id], "#e": [card], + "kinds": [7], "authors": approvers, "#h": [s.channel_id], "#e": [card], "limit": super::RECOVER_LIMIT, }); let events = query(ctx, s, filter, now).await?; @@ -199,18 +209,60 @@ pub(crate) async fn answer_card( }) .map(|e| (e.id.to_hex(), e.content.clone())) .collect(); - let plan = plan_answer(&existing, chosen, n); + let mut plan = plan_answer(&existing, chosen, n); + // What pick_answer reads once the planned deletes land. + let remaining: Vec = events + .into_iter() + .filter(|e| !plan.delete.contains(&e.id.to_hex())) + .collect(); + let writer_hex = ctx.writer.public_key().to_hex(); + let top = item::pick_answer(item, &remaining, &approvers, &writer_hex); + // Owner keycaps to delete before the same keycap is posted again: the + // relay refuses an identical active reaction as a duplicate. + let mut repost: Vec = Vec::new(); + match (chosen, top) { + // Only that key can delete its own tap. + (None, Some(_)) => return Err(ANSWERED_ELSEWHERE.to_string()), + // The owner's keycap is there but a newer tap outranks it. + (Some(i), Some((shown, _))) if shown != i && plan.add.is_none() => { + let keycap = OPTION_EMOJI[i]; + repost = existing + .iter() + .filter(|(_, c)| c == keycap) + .map(|(id, _)| id.clone()) + .collect(); + plan.add = Some(keycap); + } + _ => {} + } + // Latest wins by created_at: the add is stamped past the newest tap left + // standing, so a phone clock running ahead cannot keep outranking it. + let at = top.map_or(now, |(_, e)| now.max(e.created_at.as_secs() + 1)); + if plan.add.is_some() && at - now > super::MAX_CLOCK_LEAD_SECS { + return Err("another device's tap is dated in the future; change it there".to_string()); + } let target = nostr::EventId::from_hex(&card).map_err(|e| e.to_string())?; + for id in &repost { + remove_reaction(ctx, id).await?; + } if let Some(emoji) = plan.add { - post_as_owner(ctx, events::build_reaction(target, emoji)?).await?; + let reaction = + events::build_reaction(target, emoji)?.custom_created_at(nostr::Timestamp::from(at)); + post_as_owner(ctx, reaction).await?; } - for id in plan.delete { - let id = nostr::EventId::from_hex(&id).map_err(|e| e.to_string())?; - post_as_owner(ctx, events::build_remove_reaction(id)?).await?; + for id in &plan.delete { + remove_reaction(ctx, id).await?; } Ok(()) } +/// The owner's kind 5 on one of the owner's own reactions. +async fn remove_reaction(ctx: &Ctx<'_>, id: &str) -> Result<(), String> { + let id = nostr::EventId::from_hex(id).map_err(|e| e.to_string())?; + post_as_owner(ctx, events::build_remove_reaction(id)?).await?; + Ok(()) +} + /// `option`: Some(i) answers with option i (0-based), None clears the answer. /// The Bench relay and keys, never the app's active community. #[tauri::command] diff --git a/desktop/src-tauri/src/bench/tests/desktop_cards.rs b/desktop/src-tauri/src/bench/tests/desktop_cards.rs index 322b67e61c5..84fdab9f0d6 100644 --- a/desktop/src-tauri/src/bench/tests/desktop_cards.rs +++ b/desktop/src-tauri/src/bench/tests/desktop_cards.rs @@ -3,7 +3,7 @@ use super::answers::{last_query, outbox, reply, tap, ITEM}; use super::*; -use crate::bench::commands::{answer_card, cards, plan_answer, AnswerPlan}; +use crate::bench::commands::{answer_card, cards, plan_answer, AnswerPlan, ANSWERED_ELSEWHERE}; fn plan(add: Option<&'static str>, delete: &[&str]) -> AnswerPlan { AnswerPlan { @@ -12,6 +12,25 @@ fn plan(add: Option<&'static str>, delete: &[&str]) -> AnswerPlan { } } +/// One bench_answer: the posts it made, as events the relay now holds. +async fn answer(b: &Bench, option: Option) -> Vec { + let before = b.posts().len(); + answer_card(&b.ctx, &b.s, ITEM, option, b.now) + .await + .unwrap(); + b.posts()[before..] + .iter() + .map(|(e, tag)| { + assert_eq!(*tag, None, "owner posts carry no auth tag"); + serde_json::from_value(e.clone()).unwrap() + }) + .collect() +} + +fn kinds(evs: &[nostr::Event]) -> Vec { + evs.iter().map(|e| e.kind.as_u16()).collect() +} + #[test] fn plan_answer_rules() { let k = OPTION_EMOJI; @@ -56,21 +75,6 @@ async fn bench_answer_posts_owner_keycaps_and_the_tick_records_them() { tap(&b.ctx.owner, &"ab".repeat(32), OPTION_EMOJI[1], b.now), ]; let mut relay: Vec = noise.to_vec(); - // One bench_answer: the posts it made, as events the relay now holds. - async fn answer(b: &Bench, option: Option) -> Vec { - let before = b.posts().len(); - answer_card(&b.ctx, &b.s, ITEM, option, b.now) - .await - .unwrap(); - b.posts()[before..] - .iter() - .map(|(e, tag)| { - assert_eq!(*tag, None, "owner posts carry no auth tag"); - serde_json::from_value(e.clone()).unwrap() - }) - .collect() - } - let kinds = |evs: &[nostr::Event]| evs.iter().map(|e| e.kind.as_u16()).collect::>(); // First answer: exactly one owner kind 7, tagged like build_reaction. reply(&b, &relay.iter().collect::>()); @@ -84,12 +88,12 @@ async fn bench_answer_posts_owner_keycaps_and_the_tick_records_them() { tap(&b.ctx.owner, &card, OPTION_EMOJI[1], 0).tags, "tags exactly like events::build_reaction" ); - // The poll's own filter, narrowed to the owner. + // The poll's own filter and approvers, narrowed to this card. assert_eq!( last_query(&b), json!([{ - "kinds": [7], "authors": [owner], "#h": [b.channel()], "#e": [card], - "limit": 500, + "kinds": [7], "authors": [owner, "cd".repeat(32)], "#h": [b.channel()], + "#e": [card], "limit": 500, }]) ); relay.push(first.clone()); @@ -141,6 +145,92 @@ async fn bench_answer_posts_owner_keycaps_and_the_tick_records_them() { assert!(outbox(&b).is_none()); } +#[tokio::test] +async fn bench_answer_follows_latest_wins_across_approver_keys() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + // The phone's own key, which answers through members.txt. + let phone = Keys::generate(); + std::fs::write(b.root.join("members.txt"), phone.public_key().to_hex()).unwrap(); + let mut relay: Vec = Vec::new(); + async fn tick_with(b: &mut Bench, relay: &[nostr::Event]) -> Option<(usize, String)> { + reply(b, &relay.iter().collect::>()); + b.tick().await.unwrap(); + let a = b.s.items[ITEM].answer.as_ref()?; + Some((a.option, a.reaction_id.clone())) + } + async fn refused(b: &Bench, option: Option) -> String { + let before = b.posts().len(); + let err = answer_card(&b.ctx, &b.s, ITEM, option, b.now) + .await + .unwrap_err(); + assert_eq!(b.posts().len(), before, "a refusal publishes nothing"); + err + } + + // The phone answers 2; undo on the desktop cannot delete its tap. + let phone_2 = tap(&phone, &card, OPTION_EMOJI[1], b.now); + relay.push(phone_2.clone()); + assert_eq!( + tick_with(&mut b, &relay).await, + Some((1, phone_2.id.to_hex())) + ); + assert_eq!(refused(&b, None).await, ANSWERED_ELSEWHERE); + assert_eq!( + last_query(&b)[0]["authors"], + json!([b.owner_hex(), phone.public_key().to_hex()]) + ); + + // Choosing 1 on the desktop outranks it. + let posted = answer(&b, Some(0)).await; + assert_eq!(kinds(&posted), [7]); + let desk_1 = posted[0].clone(); + relay.push(desk_1.clone()); + assert_eq!( + tick_with(&mut b, &relay).await, + Some((0, desk_1.id.to_hex())) + ); + // Undo here would hand the answer back to the phone's older 2. + assert_eq!(refused(&b, None).await, ANSWERED_ELSEWHERE); + + // The phone taps 2 again after the owner, its clock 100 s ahead. + relay.retain(|e| e.id != phone_2.id); + let phone_2b = tap(&phone, &card, OPTION_EMOJI[1], b.now + 100); + relay.push(phone_2b.clone()); + assert_eq!( + tick_with(&mut b, &relay).await, + Some((1, phone_2b.id.to_hex())) + ); + // Choosing 1 again: the owner's 1 is there but outranked, so it is + // deleted first (the relay refuses an active duplicate) and posted anew, + // stamped past the phone's tap. + let posted = answer(&b, Some(0)).await; + assert_eq!(kinds(&posted), [5, 7]); + let del = serde_json::to_value(&posted[0]).unwrap(); + assert_eq!(del["tags"], json!([["e", desk_1.id.to_hex()]])); + let desk_1b = posted[1].clone(); + assert_eq!(desk_1b.content, OPTION_EMOJI[0]); + assert_eq!( + desk_1b.created_at.as_secs(), + phone_2b.created_at.as_secs() + 1 + ); + relay.retain(|e| e.id != desk_1.id); + relay.push(desk_1b.clone()); + assert_eq!( + tick_with(&mut b, &relay).await, + Some((0, desk_1b.id.to_hex())) + ); + assert!(answer(&b, Some(0)).await.is_empty()); + + // A tap dated past the clock lead cannot be outranked from here, and the + // owner's keycap is not deleted trying. + relay.retain(|e| e.id != phone_2b.id); + let far = tap(&phone, &card, OPTION_EMOJI[1], b.now + 10_000); + relay.push(far.clone()); + assert_eq!(tick_with(&mut b, &relay).await, Some((1, far.id.to_hex()))); + assert!(refused(&b, Some(0)).await.contains("dated in the future")); +} + #[tokio::test] async fn bench_answer_refuses_what_it_cannot_answer() { let mut b = seeded_bench(Gate::Open, &[]).await; diff --git a/desktop/src/features/bench/lib/benchCards.test.mjs b/desktop/src/features/bench/lib/benchCards.test.mjs index 7448ba40452..d19dfb36da2 100644 --- a/desktop/src/features/bench/lib/benchCards.test.mjs +++ b/desktop/src/features/bench/lib/benchCards.test.mjs @@ -477,6 +477,43 @@ test("recorded line: option number, text and the answer time", () => { assert.equal(benchRecordedLine(card({ recorded: 5 }), formatTime), null); }); +test("retired card: the Bench's answer still shows, read-only", () => { + // state.json 24 h after the answer: the card is retired, `recorded` is + // forgotten and `answer` stays. + const retired = card({ + cardEventId: null, + answer: { option: 1, answeredAt: 99 }, + recorded: null, + }); + assert.equal( + benchRecordedLine(retired, (s) => `t${s}`), + "Recorded: 2. Hold · t99", + ); + const mode = benchCardMode(retired, OWNER, OWNER); + assert.equal(mode.interactive, false); + assert.deepEqual( + benchOptionViews(retired, INITIAL_BENCH_ANSWER_STATE, mode.interactive).map( + (o) => [o.selected, o.disabled], + ), + [ + [false, true], + [true, true], + ], + ); + // A live card shows only what its text records, not an answer the Bench + // has yet to edit in. + const live = card({ answer: { option: 1, answeredAt: 99 }, recorded: null }); + assert.equal( + benchRecordedLine(live, (s) => `t${s}`), + null, + ); + assert.ok( + benchOptionViews(live, INITIAL_BENCH_ANSWER_STATE, true).every( + (o) => !o.selected, + ), + ); +}); + test("deadline: RFC 3339 to unix seconds, null when unparseable", () => { assert.equal(benchDeadlineSeconds("2026-10-14T15:00:00Z"), 1_791_990_000); assert.equal(benchDeadlineSeconds("soon"), null); diff --git a/desktop/src/features/bench/lib/benchCards.ts b/desktop/src/features/bench/lib/benchCards.ts index 85d4d4a44b2..4b07d78ccee 100644 --- a/desktop/src/features/bench/lib/benchCards.ts +++ b/desktop/src/features/bench/lib/benchCards.ts @@ -405,19 +405,32 @@ export type BenchOptionView = { disabled: boolean; }; +/** + * The option the card shows as answered: what the live card's text records, + * or, once the card is retired or deleted (the Bench forgets `recorded` then + * and keeps `answer`), the Bench's answer. Such a card is never interactive, + * so the reducer still settles on `recorded` alone. + */ +function shownOption(card: BenchCard): number | null { + return ( + card.recorded ?? (card.cardEventId ? null : (card.answer?.option ?? null)) + ); +} + export function benchOptionViews( card: BenchCard, state: BenchAnswerState, interactive: boolean, ): BenchOptionView[] { const saving = state.phase === "saving"; + const shown = shownOption(card); return card.options.map((text, index) => { const pending = saving && state.clicked === index; return { index, number: String(index + 1), text, - selected: card.recorded === index, + selected: shown === index, pending, disabled: !interactive || (saving && !pending), }; @@ -429,7 +442,7 @@ export function benchRecordedLine( card: BenchCard, formatTime: (unixSeconds: number) => string, ): string | null { - const recorded = card.recorded; + const recorded = shownOption(card); if (recorded === null || recorded < 0) return null; const text = card.options[recorded]; if (text === undefined) return null; diff --git a/desktop/src/features/bench/ui/BenchCard.tsx b/desktop/src/features/bench/ui/BenchCard.tsx index 598d87ba990..cd3185b6e34 100644 --- a/desktop/src/features/bench/ui/BenchCard.tsx +++ b/desktop/src/features/bench/ui/BenchCard.tsx @@ -203,11 +203,17 @@ function BenchOptionButton({ option: BenchOptionView; }) { const label = `${option.number}. ${option.text}`; + // A retired card shows its answer on a disabled button: nothing to undo. + const undoable = option.selected && !option.disabled; return ( ) : null; + // Shown only when the canvas has something to read; it stays visible while + // the panel is open so the same button can close it. + const aboutButton = + activeChannel !== null && + onToggleChannelAbout && + (hasChannelAbout || channelAboutOpen) ? ( + + + + + {CHANNEL_ABOUT_TITLE} + + ) : null; const channelActions = activeChannel ? ( showJoinButton ? (
@@ -199,8 +232,9 @@ export function ChannelScreenHeader({ headerEndActions ); const actions = - terminalButton || channelActions ? ( + aboutButton || terminalButton || channelActions ? (
+ {aboutButton} {terminalButton} {channelActions}
diff --git a/desktop/src/features/channels/ui/useChannelAbout.ts b/desktop/src/features/channels/ui/useChannelAbout.ts new file mode 100644 index 00000000000..dd57d05d4b6 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelAbout.ts @@ -0,0 +1,111 @@ +import * as React from "react"; + +import { useCanvasQuery } from "@/features/channels/hooks"; +import { + hasChannelCanvasContent, + hasSeenChannelAbout, + markChannelAboutSeen, + shouldAutoOpenChannelAbout, +} from "@/features/channels/lib/channelAbout"; +import { isWelcomeChannel } from "@/features/onboarding/welcome"; +import type { Channel } from "@/shared/api/types"; +import type { PanelSetterOptions } from "./useChannelPanelHistoryState"; + +type UseChannelAboutOptions = { + activeChannel: Channel | null; + /** + * The window is wide enough to show the panel beside the messages. A narrow + * window would swap the messages out, so About never opens there by itself. + */ + canOpenBesideChannel: boolean; + channelAboutOpen: boolean; + /** False where the channel shows no header, such as a huddle transcript. */ + enabled: boolean; + /** Closes the thread, profile and agent activity panels, then runs `open`. */ + openSidePanel: (open: () => void) => void; + /** A thread, profile, agent activity or settings panel holds the side panel. */ + otherPanelOpen: boolean; + requireThreadEditResolution: () => boolean; + setChannelAboutOpen: (open: boolean, options?: PanelSetterOptions) => void; +}; + +/** + * Drives the header's "About this channel" button: whether the channel has a + * canvas to show, opening it once by itself on this device's first visit, and + * toggling it from the button. Only stream channels have it. + */ +export function useChannelAbout({ + activeChannel, + canOpenBesideChannel, + channelAboutOpen, + enabled, + openSidePanel, + otherPanelOpen, + requireThreadEditResolution, + setChannelAboutOpen, +}: UseChannelAboutOptions) { + const channelId = + enabled && activeChannel?.channelType === "stream" + ? activeChannel.id + : null; + const canvasQuery = useCanvasQuery(channelId, channelId !== null); + const hasChannelAbout = + channelId !== null && hasChannelCanvasContent(canvasQuery.data); + // The private Welcome channel guides new people with its own kickoff; its + // canvas stays one click away instead of opening over that. + const canAutoOpen = channelId !== null && !isWelcomeChannel(activeChannel); + const canvasLoaded = canvasQuery.isSuccess; + + // Decide once per visit, when the canvas first answers. Opening or closing + // other panels later in the same visit never brings About back by itself. + const decidedChannelIdRef = React.useRef(null); + React.useEffect(() => { + if (channelId === null || !canvasLoaded) return; + if (decidedChannelIdRef.current === channelId) return; + decidedChannelIdRef.current = channelId; + if (channelAboutOpen) { + markChannelAboutSeen(channelId); + return; + } + if ( + !canAutoOpen || + !canOpenBesideChannel || + !shouldAutoOpenChannelAbout({ + hasContent: hasChannelAbout, + otherPanelOpen, + seen: hasSeenChannelAbout(channelId), + }) + ) { + return; + } + markChannelAboutSeen(channelId); + setChannelAboutOpen(true, { replace: true }); + }, [ + canAutoOpen, + canOpenBesideChannel, + canvasLoaded, + channelAboutOpen, + channelId, + hasChannelAbout, + otherPanelOpen, + setChannelAboutOpen, + ]); + + const toggleChannelAbout = React.useCallback(() => { + if (!requireThreadEditResolution()) return; + if (channelAboutOpen) { + setChannelAboutOpen(false); + return; + } + if (channelId) markChannelAboutSeen(channelId); + openSidePanel(() => setChannelAboutOpen(true)); + }, [ + channelAboutOpen, + channelId, + openSidePanel, + requireThreadEditResolution, + setChannelAboutOpen, + ]); + + return { hasChannelAbout, toggleChannelAbout }; +} diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 60e3a291b21..3cefcd18fc3 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -26,8 +26,9 @@ export type { ChannelSearchKey } from "./channelSearchKeys"; * `profileView` (profile panel focused view), `profileTab` (profile summary * tab), `agentSession` (agent session panel pubkey), `agentSessionChannel` * (optional channel scope for the agent session panel), `channelManagement` - * (presence flag for the channel-management panel — open/closed only, so it - * carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit + * (the channel side panel: `"1"` for channel settings, `"about"` for the + * read-only About this channel view; both share one slot, so anything that + * closes channel settings closes About too), `autoSend` (draft auto-submit * trigger — cleared surgically after the auto-submit fires so `thread` and * all other panel state are preserved). */ @@ -40,6 +41,7 @@ export type PanelValueSetter = ( ) => void; const CHANNEL_MANAGEMENT_OPEN_VALUE = "1"; +const CHANNEL_ABOUT_OPEN_VALUE = "about"; export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -105,6 +107,15 @@ export function useChannelPanelHistoryState() { [applyPatch], ); + const setChannelAboutOpen = React.useCallback( + (open: boolean, options?: PanelSetterOptions) => + applyPatch( + { channelManagement: open ? CHANNEL_ABOUT_OPEN_VALUE : null }, + options, + ), + [applyPatch], + ); + const clearMessageRouteTarget = React.useCallback( (options?: PanelSetterOptions) => applyPatch({ messageId: null, threadRootId: null }, options), @@ -122,6 +133,8 @@ export function useChannelPanelHistoryState() { ); return { + channelAboutOpen: values.channelManagement === CHANNEL_ABOUT_OPEN_VALUE, + /** True while the channel side panel shows settings or About. */ channelManagementOpen: values.channelManagement != null, clearAutoSend, clearMessageRouteTarget, @@ -132,6 +145,7 @@ export function useChannelPanelHistoryState() { profilePanelPubkey: values.profile, profilePanelTab: profilePanelTabFromSearch(values.profileTab), profilePanelView: profilePanelViewFromSearch(values.profileView), + setChannelAboutOpen, setChannelManagementOpen, setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, diff --git a/desktop/tests/e2e/channel-about.spec.ts b/desktop/tests/e2e/channel-about.spec.ts new file mode 100644 index 00000000000..f7be2182eab --- /dev/null +++ b/desktop/tests/e2e/channel-about.spec.ts @@ -0,0 +1,129 @@ +import { expect, type Page, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// The mock bridge seeds canvas revisions on the starter #general channel. +const CANVAS = "# Start here\n\nAsk the Reset Worker when you need a hand."; +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const GENERAL_THREAD_ROOT_ID = "mock-general-welcome"; + +async function openChannel(page: Page, name: string) { + await page.getByTestId(`channel-${name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(name); +} + +test("About this channel opens by itself once, then from its header button", async ({ + page, +}) => { + await installMockBridge(page, { canvasRevisions: [{ content: CANVAS }] }); + await page.goto("/"); + await openChannel(page, "general"); + + const about = page.getByTestId("channel-about-panel"); + const trigger = page.getByTestId("channel-about-trigger"); + await expect(about).toContainText("Start here"); + await expect( + page.getByRole("heading", { name: "About this channel" }), + ).toBeVisible(); + await expect(trigger).toHaveAccessibleName("About this channel"); + await expect(trigger).toHaveAttribute("aria-pressed", "true"); + // Read-only, even for someone who can edit the canvas in channel settings. + await expect(about.getByTestId("channel-canvas-edit")).toHaveCount(0); + await expect(about.getByTestId("channel-canvas-editor")).toHaveCount(0); + + await trigger.click(); + await expect(about).toHaveCount(0); + await expect(trigger).toHaveAttribute("aria-pressed", "false"); + + // A channel without a canvas has no button. + await openChannel(page, "random"); + await expect(trigger).toHaveCount(0); + await expect(about).toHaveCount(0); + + // Coming back, or restarting the app, does not open it again by itself. + await openChannel(page, "general"); + await expect(trigger).toBeVisible(); + await expect(about).toHaveCount(0); + await page.reload(); + await openChannel(page, "general"); + await expect(trigger).toBeVisible(); + await expect(about).toHaveCount(0); + + await trigger.click(); + await expect(about).toContainText("Start here"); + await expect(trigger).toHaveAttribute("aria-pressed", "true"); +}); + +test("About and channel settings take turns in the side panel", async ({ + page, +}) => { + await installMockBridge(page, { canvasRevisions: [{ content: CANVAS }] }); + await page.goto("/"); + await openChannel(page, "general"); + + const about = page.getByTestId("channel-about-panel"); + const trigger = page.getByTestId("channel-about-trigger"); + const settings = page.getByTestId("channel-management-sheet"); + await expect(about).toBeVisible(); + + await page.getByTestId("channel-management-trigger").click(); + await expect(settings).toBeVisible(); + await expect(about).toHaveCount(0); + await expect(trigger).toHaveAttribute("aria-pressed", "false"); + + await trigger.click(); + await expect(about).toContainText("Start here"); + await expect(settings).toHaveCount(0); + + await page.getByTestId("auxiliary-panel-close").click(); + await expect(about).toHaveCount(0); + await expect(settings).toHaveCount(0); +}); + +test("a link that opens a thread is not covered by About", async ({ page }) => { + await installMockBridge(page, { canvasRevisions: [{ content: CANVAS }] }); + await page.goto( + `/#/channels/${GENERAL_CHANNEL_ID}?messageId=${GENERAL_THREAD_ROOT_ID}&thread=${GENERAL_THREAD_ROOT_ID}`, + { waitUntil: "domcontentloaded" }, + ); + + const about = page.getByTestId("channel-about-panel"); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page.getByTestId("channel-about-trigger")).toBeVisible(); + await expect(about).toHaveCount(0); + + // It still counts as unseen, so the next plain visit opens it. + await openChannel(page, "random"); + await openChannel(page, "general"); + await expect(about).toContainText("Start here"); +}); + +test("a narrow window keeps the messages and leaves About to the button", async ({ + page, +}) => { + await page.setViewportSize({ height: 720, width: 820 }); + await installMockBridge(page, { canvasRevisions: [{ content: CANVAS }] }); + await page.goto("/"); + await openChannel(page, "general"); + + const about = page.getByTestId("channel-about-panel"); + const trigger = page.getByTestId("channel-about-trigger"); + await expect(trigger).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toBeVisible(); + await expect(about).toHaveCount(0); + + await trigger.click(); + await expect(about).toContainText("Start here"); +}); + +test("channels without a canvas show no About button and open nothing", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await openChannel(page, "general"); + + await expect(page.getByTestId("message-timeline")).toBeVisible(); + await expect(page.getByTestId("channel-about-trigger")).toHaveCount(0); + await expect(page.getByTestId("channel-about-panel")).toHaveCount(0); +}); From cfea34430c172925c4ee1a0a0b2c769330bb79ba Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 13:23:12 -0500 Subject: [PATCH 20/28] feat(desktop): show each agent's description in the @-mention list The @-mention list showed an agent's name, "agent" and who manages it, so a guest could not tell what an agent is for. Each agent row now has a second line with the agent's public description (its profile About text, or the persona description for an agent on this desktop that has no About yet), cut to one line, and the mention button is described by it for screen readers. When an agent's known reply setting leaves out the person typing (Only me, or Selected people without them), the row says "Answers only " in place of "managed by ". The setting comes only from data the app already has: this desktop's agents, and the owner's published setting in the community's agent directory for agents run elsewhere. An agent whose setting is unknown gets no note. About text comes from each agent's public profile, looked up for at most the first 20 agents in the list, in its own cache entry under the user-profile key so profile refreshes cover it. Messages from agents that answer anyone carried an amber warning triangle on the avatar; that is now a neutral globe with the same label. Unit tests cover the one-line description, where the reply setting comes from, every combination that leaves the viewer out, the note text, the lookup cap, and the list rows (description, note, screen-reader description, people rows untouched). A new E2E spec covers a description from an agent run elsewhere and the neutral icon. Co-Authored-By: Claude Opus 5.5 --- desktop/playwright.config.ts | 1 + .../messages/lib/mentionAgentDetails.test.mjs | 265 ++++++++++++++++++ .../messages/lib/mentionAgentDetails.ts | 197 +++++++++++++ .../messages/lib/useMentionAgentDetails.ts | 85 ++++++ .../src/features/messages/lib/useMentions.ts | 10 +- .../messages/ui/MentionAutocomplete.test.mjs | 119 ++++++++ .../messages/ui/MentionAutocomplete.tsx | 73 ++++- .../src/features/messages/ui/MessageRow.tsx | 7 +- .../tests/e2e/mention-agent-details.spec.ts | 107 +++++++ 9 files changed, 845 insertions(+), 19 deletions(-) create mode 100644 desktop/src/features/messages/lib/mentionAgentDetails.test.mjs create mode 100644 desktop/src/features/messages/lib/mentionAgentDetails.ts create mode 100644 desktop/src/features/messages/lib/useMentionAgentDetails.ts create mode 100644 desktop/tests/e2e/mention-agent-details.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index cb2c7e6170a..8f07f152d96 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -84,6 +84,7 @@ export default defineConfig({ "**/mention-clipboard.spec.ts", "**/cloud-provenance.spec.ts", "**/mention-recipients.spec.ts", + "**/mention-agent-details.spec.ts", "**/remote-owned-mentions.spec.ts", "**/forum-agent-invitation.spec.ts", "**/team-mentions.spec.ts", diff --git a/desktop/src/features/messages/lib/mentionAgentDetails.test.mjs b/desktop/src/features/messages/lib/mentionAgentDetails.test.mjs new file mode 100644 index 00000000000..a2b8ecccad1 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionAgentDetails.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentIgnoresViewer, + MENTION_AGENT_DETAIL_LIMIT, + mentionAgentAudienceNote, + mentionAgentDescription, + mentionAgentDetailPubkeys, + resolveAgentRespondPolicy, + withMentionAgentDetails, +} from "./mentionAgentDetails.ts"; + +const VIEWER = "a".repeat(64); +const OWNER = "b".repeat(64); +const AGENT = "c".repeat(64); +const OTHER_AGENT = "d".repeat(64); +const OWNER_PROFILES = { + [OWNER]: { displayName: "Russell", avatarUrl: null, nip05Handle: null }, +}; + +function relayAgent(overrides = {}) { + return { + pubkey: AGENT, + ownerPubkey: OWNER, + name: "Reset Worker", + agentType: "goose", + channels: [], + channelIds: [], + capabilities: [], + status: "online", + respondTo: "anyone", + respondToAllowlist: [], + ...overrides, + }; +} + +function managedAgent(overrides = {}) { + return { + pubkey: AGENT, + name: "Reset Worker", + personaId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function policy(overrides = {}) { + return { + ownerPubkey: OWNER, + respondTo: "anyone", + respondToAllowlist: [], + ...overrides, + }; +} + +test("descriptions collapse to one trimmed line", () => { + assert.equal(mentionAgentDescription(null), null); + assert.equal(mentionAgentDescription(undefined), null); + assert.equal(mentionAgentDescription(" \n "), null); + assert.equal( + mentionAgentDescription(" Plans your week.\n\nAsk me for a check-in. "), + "Plans your week. Ask me for a check-in.", + ); +}); + +test("an agent on this desktop is the viewer's own and its setting wins", () => { + const resolved = resolveAgentRespondPolicy(AGENT.toUpperCase(), { + currentPubkey: VIEWER, + managedAgents: [managedAgent({ respondTo: "allowlist" })], + relayAgents: [relayAgent({ respondTo: "owner-only" })], + }); + assert.deepEqual(resolved, { + ownerPubkey: VIEWER, + respondTo: "allowlist", + respondToAllowlist: [], + }); +}); + +test("an agent run elsewhere uses the owner's published setting", () => { + assert.deepEqual( + resolveAgentRespondPolicy(AGENT, { + currentPubkey: VIEWER, + managedAgents: [], + relayAgents: [relayAgent({ respondTo: "allowlist" })], + }), + { ownerPubkey: OWNER, respondTo: "allowlist", respondToAllowlist: [] }, + ); + assert.equal( + resolveAgentRespondPolicy(OTHER_AGENT, { + currentPubkey: VIEWER, + managedAgents: undefined, + relayAgents: [relayAgent()], + }), + null, + ); +}); + +test("only a known setting that leaves the viewer out counts", () => { + const cases = [ + [policy({ respondTo: "anyone" }), false], + [policy({ respondTo: "owner-only" }), true], + [policy({ respondTo: "allowlist", respondToAllowlist: [] }), true], + [ + policy({ respondTo: "allowlist", respondToAllowlist: [OWNER, VIEWER] }), + false, + ], + [ + policy({ + respondTo: "allowlist", + respondToAllowlist: [VIEWER.toUpperCase()], + }), + false, + ], + [policy({ respondTo: null }), false], + [policy({ ownerPubkey: VIEWER, respondTo: "owner-only" }), false], + [policy({ ownerPubkey: VIEWER, respondTo: "allowlist" }), false], + [null, false], + ]; + for (const [value, expected] of cases) { + assert.equal( + agentIgnoresViewer(value, VIEWER), + expected, + JSON.stringify(value), + ); + } + assert.equal( + agentIgnoresViewer(policy({ respondTo: "owner-only" }), null), + false, + ); +}); + +test("the note names the owner the agent answers", () => { + assert.equal( + mentionAgentAudienceNote( + policy({ respondTo: "owner-only" }), + VIEWER, + OWNER_PROFILES, + ), + "Answers only Russell", + ); + assert.equal( + mentionAgentAudienceNote( + policy({ respondTo: "allowlist" }), + VIEWER, + OWNER_PROFILES, + ), + "Answers only Russell", + ); + assert.match( + mentionAgentAudienceNote(policy({ respondTo: "owner-only" }), VIEWER, {}), + /^Answers only npub1/, + ); + assert.equal( + mentionAgentAudienceNote( + policy({ ownerPubkey: null, respondTo: "owner-only" }), + VIEWER, + OWNER_PROFILES, + ), + null, + ); + assert.equal( + mentionAgentAudienceNote(policy(), VIEWER, OWNER_PROFILES), + null, + ); +}); + +test("profiles are looked up only for agent identities, at most a few", () => { + const suggestions = [ + { displayName: "Ada", pubkey: "e".repeat(64) }, + { displayName: "Team", kind: "team", isAgent: true, teamId: "t1" }, + { displayName: "Persona", kind: "persona", isAgent: true, personaId: "p1" }, + { displayName: "Worker", isAgent: true, pubkey: AGENT.toUpperCase() }, + { displayName: "Worker again", isAgent: true, pubkey: AGENT }, + ...Array.from({ length: MENTION_AGENT_DETAIL_LIMIT + 5 }, (_, index) => ({ + displayName: `Agent ${index}`, + isAgent: true, + pubkey: index.toString(16).padStart(64, "0"), + })), + ]; + const pubkeys = mentionAgentDetailPubkeys(suggestions); + assert.equal(pubkeys.length, MENTION_AGENT_DETAIL_LIMIT); + assert.equal(pubkeys[0], AGENT); + assert.equal(new Set(pubkeys).size, pubkeys.length); +}); + +test("agent suggestions gain the About line and the audience note", () => { + const person = { displayName: "Ada", pubkey: "e".repeat(64) }; + const worker = { displayName: "Reset Worker", isAgent: true, pubkey: AGENT }; + const persona = { + displayName: "Planner", + isAgent: true, + kind: "persona", + personaId: "p1", + }; + const team = { displayName: "Crew", isAgent: true, kind: "team" }; + const next = withMentionAgentDetails([person, worker, persona, team], { + aboutByPubkey: new Map([[AGENT, "Builds your\nplan."]]), + currentPubkey: VIEWER, + managedAgents: [], + ownerProfiles: OWNER_PROFILES, + personas: [{ id: "p1", description: " Plans the week. " }], + relayAgents: [relayAgent({ respondTo: "owner-only" })], + }); + + assert.equal(next[0], person); + assert.deepEqual(next[1], { + ...worker, + audienceNote: "Answers only Russell", + description: "Builds your plan.", + }); + assert.deepEqual(next[2], { + ...persona, + audienceNote: null, + description: "Plans the week.", + }); + assert.equal(next[3], team); +}); + +test("an agent with no About falls back to its persona description", () => { + const worker = { + displayName: "Reset Worker", + isAgent: true, + personaId: "p1", + pubkey: AGENT, + }; + const [next] = withMentionAgentDetails([worker], { + aboutByPubkey: new Map(), + currentPubkey: VIEWER, + managedAgents: [managedAgent({ personaId: "p1" })], + personas: [{ id: "p1", description: "Builds your plan." }], + relayAgents: [], + }); + assert.equal(next.description, "Builds your plan."); + assert.equal(next.audienceNote, null); +}); + +test("the list keeps its identity when nothing changes", () => { + const suggestions = [ + { displayName: "Ada", pubkey: "e".repeat(64) }, + { displayName: "Reset Worker", isAgent: true, pubkey: AGENT }, + ]; + const options = { + aboutByPubkey: new Map(), + currentPubkey: VIEWER, + managedAgents: [], + personas: [], + relayAgents: [relayAgent()], + }; + assert.equal(withMentionAgentDetails(suggestions, options), suggestions); + + const described = withMentionAgentDetails(suggestions, { + ...options, + aboutByPubkey: new Map([[AGENT, "Builds your plan."]]), + }); + assert.notEqual(described, suggestions); + assert.equal( + withMentionAgentDetails(described, { + ...options, + aboutByPubkey: new Map([[AGENT, "Builds your plan."]]), + }), + described, + ); +}); diff --git a/desktop/src/features/messages/lib/mentionAgentDetails.ts b/desktop/src/features/messages/lib/mentionAgentDetails.ts new file mode 100644 index 00000000000..31264c8616a --- /dev/null +++ b/desktop/src/features/messages/lib/mentionAgentDetails.ts @@ -0,0 +1,197 @@ +import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription"; +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import { + formatOwnerLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import type { + AgentPersona, + ManagedAgent, + RelayAgent, + RespondToMode, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Extra lines the @-mention list shows under an agent: its public + * description (the profile About text) and, when its reply setting leaves out + * the person typing, who it does answer. Built only from data the app already + * holds: this desktop's agents, the community's agent directory, the + * agents' public profiles and local personas. + */ + +/** Agents whose public profile the list looks up at once. */ +export const MENTION_AGENT_DETAIL_LIMIT = 20; + +export type AgentRespondPolicy = { + ownerPubkey: string | null; + respondTo: RespondToMode | null; + respondToAllowlist: readonly string[]; +}; + +/** One line of About text, or null when there is none. */ +export function mentionAgentDescription( + about: string | null | undefined, +): string | null { + const line = about?.replace(/\s+/g, " ").trim() ?? ""; + return line.length > 0 ? line : null; +} + +/** + * The reply setting the app knows for an agent. An agent on this desktop is + * the viewer's own and its local setting wins; otherwise the community's + * agent directory carries the owner's published setting, when there is one. + */ +export function resolveAgentRespondPolicy( + pubkey: string, + { + currentPubkey, + managedAgents, + relayAgents, + }: { + currentPubkey: string | null; + managedAgents: readonly ManagedAgent[] | undefined; + relayAgents: readonly RelayAgent[] | undefined; + }, +): AgentRespondPolicy | null { + const key = normalizePubkey(pubkey); + const managed = managedAgents?.find( + (agent) => normalizePubkey(agent.pubkey) === key, + ); + if (managed) { + return { + ownerPubkey: currentPubkey, + respondTo: managed.respondTo, + respondToAllowlist: managed.respondToAllowlist, + }; + } + const relay = relayAgents?.find( + (agent) => normalizePubkey(agent.pubkey) === key, + ); + return relay + ? { + ownerPubkey: relay.ownerPubkey, + respondTo: relay.respondTo, + respondToAllowlist: relay.respondToAllowlist, + } + : null; +} + +/** + * Whether the agent's known reply setting leaves out the person typing. Its + * owner is always answered; an unknown setting is never treated as a refusal. + */ +export function agentIgnoresViewer( + policy: AgentRespondPolicy | null, + currentPubkey: string | null, +): boolean { + if (!policy?.respondTo || !currentPubkey) return false; + const viewer = normalizePubkey(currentPubkey); + if (policy.ownerPubkey && normalizePubkey(policy.ownerPubkey) === viewer) { + return false; + } + if (policy.respondTo === "owner-only") return true; + if (policy.respondTo === "allowlist") { + return !policy.respondToAllowlist.some( + (pubkey) => normalizePubkey(pubkey) === viewer, + ); + } + return false; +} + +/** "Answers only " when the agent will not answer the person typing. */ +export function mentionAgentAudienceNote( + policy: AgentRespondPolicy | null, + currentPubkey: string | null, + ownerProfiles?: UserProfileLookup, +): string | null { + if (!policy?.ownerPubkey || !agentIgnoresViewer(policy, currentPubkey)) { + return null; + } + const owner = formatOwnerLabel( + policy.ownerPubkey, + currentPubkey, + ownerProfiles, + ); + return owner ? `Answers only ${owner}` : null; +} + +/** Agent identities in the list whose public profile is worth fetching. */ +export function mentionAgentDetailPubkeys( + suggestions: readonly MentionSuggestion[], +): string[] { + const pubkeys: string[] = []; + for (const suggestion of suggestions) { + if (!suggestion.isAgent || !suggestion.pubkey) continue; + if (suggestion.kind === "team" || suggestion.kind === "persona") continue; + const pubkey = normalizePubkey(suggestion.pubkey); + if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey); + if (pubkeys.length === MENTION_AGENT_DETAIL_LIMIT) break; + } + return pubkeys; +} + +/** + * Adds the description and audience note to agent suggestions. Returns the + * same array when nothing changes, so the list keeps its identity. + */ +export function withMentionAgentDetails( + suggestions: MentionSuggestion[], + { + aboutByPubkey, + currentPubkey, + managedAgents, + ownerProfiles, + personas, + relayAgents, + }: { + aboutByPubkey: ReadonlyMap; + currentPubkey: string | null; + managedAgents: readonly ManagedAgent[] | undefined; + ownerProfiles?: UserProfileLookup; + personas: readonly AgentPersona[] | undefined; + relayAgents: readonly RelayAgent[] | undefined; + }, +): MentionSuggestion[] { + const personaById = new Map( + (personas ?? []).map((persona) => [persona.id, persona]), + ); + const personaDescription = (personaId: string | undefined) => { + const persona = personaId ? personaById.get(personaId) : undefined; + return persona ? effectiveAgentDescription(persona) : null; + }; + let changed = false; + const next = suggestions.map((suggestion) => { + if (!suggestion.isAgent || suggestion.kind === "team") return suggestion; + const pubkey = suggestion.pubkey + ? normalizePubkey(suggestion.pubkey) + : null; + const description = + suggestion.kind === "persona" + ? mentionAgentDescription(personaDescription(suggestion.personaId)) + : ((pubkey + ? mentionAgentDescription(aboutByPubkey.get(pubkey)) + : null) ?? + mentionAgentDescription(personaDescription(suggestion.personaId))); + const audienceNote = pubkey + ? mentionAgentAudienceNote( + resolveAgentRespondPolicy(pubkey, { + currentPubkey, + managedAgents, + relayAgents, + }), + currentPubkey, + ownerProfiles, + ) + : null; + if ( + (suggestion.description ?? null) === description && + (suggestion.audienceNote ?? null) === audienceNote + ) { + return suggestion; + } + changed = true; + return { ...suggestion, audienceNote, description }; + }); + return changed ? next : suggestions; +} diff --git a/desktop/src/features/messages/lib/useMentionAgentDetails.ts b/desktop/src/features/messages/lib/useMentionAgentDetails.ts new file mode 100644 index 00000000000..c453d8ff4ef --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAgentDetails.ts @@ -0,0 +1,85 @@ +import * as React from "react"; +import { useQueries } from "@tanstack/react-query"; + +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { getUserProfile } from "@/shared/api/tauriProfiles"; +import type { + AgentPersona, + ManagedAgent, + RelayAgent, +} from "@/shared/api/types"; +import { + mentionAgentDetailPubkeys, + withMentionAgentDetails, +} from "./mentionAgentDetails"; + +/** + * An agent's About text from its public profile. It lives under the + * "user-profile" key so every profile refresh (an agent rename, a persona + * edit) refreshes it too, but in its own entry: the shared ["user-profile", + * pubkey] entry is often seeded from the people list, which carries no About. + */ +function agentAboutQueryOptions(pubkey: string) { + return { + queryKey: ["user-profile", pubkey, "about"], + queryFn: async () => (await getUserProfile(pubkey)).about, + staleTime: 60_000, + }; +} + +/** + * Adds each listed agent's About text and, when it will not answer the person + * typing, who it does answer. About text is looked up for at most the first + * few agents in the list. + */ +export function useMentionAgentDetails( + suggestions: MentionSuggestion[], + options: { + currentPubkey: string | null; + managedAgents: readonly ManagedAgent[] | undefined; + ownerProfiles?: UserProfileLookup; + personas: readonly AgentPersona[] | undefined; + relayAgents: readonly RelayAgent[] | undefined; + }, +): MentionSuggestion[] { + const pubkeys = React.useMemo( + () => mentionAgentDetailPubkeys(suggestions), + [suggestions], + ); + // Joined into one string so the list only changes when an About does. + const abouts = useQueries({ + queries: pubkeys.map(agentAboutQueryOptions), + combine: (results) => + results.map((result) => result.data ?? "").join("\u0000"), + }); + const aboutByPubkey = React.useMemo(() => { + const values = abouts.split("\u0000"); + return new Map( + pubkeys.map((pubkey, index) => [pubkey, values[index] || null]), + ); + }, [abouts, pubkeys]); + const { currentPubkey, managedAgents, ownerProfiles, personas, relayAgents } = + options; + + return React.useMemo( + () => + withMentionAgentDetails(suggestions, { + aboutByPubkey, + currentPubkey, + managedAgents, + ownerProfiles, + personas, + relayAgents, + }), + [ + aboutByPubkey, + currentPubkey, + managedAgents, + ownerProfiles, + personas, + relayAgents, + suggestions, + ], + ); +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 63f8aaa12be..31a0c1d9ecb 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -34,6 +34,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; +import { useMentionAgentDetails } from "./useMentionAgentDetails"; import { flushMentionDebounce, isPlainSpace } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import type { MentionIdentity } from "./mentionClipboard"; @@ -418,7 +419,7 @@ export function useMentions( void userSearchQuery.fetchNextPage(); } }, [userSearchQuery]); - const suggestions = React.useMemo(() => { + const rankedSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; } @@ -438,6 +439,13 @@ export function useMentions( mentionQuery, userSearchQuery.isFetching, ]); + const suggestions = useMentionAgentDetails(rankedSuggestions, { + currentPubkey, + managedAgents: managedAgentsQuery.data, + ownerProfiles: ownerProfilesQuery.data?.profiles, + personas: personasQuery.data, + relayAgents: relayAgentsQuery.data, + }); React.useEffect(() => { if (mentionQuery === null) { previousSuggestionsRef.current = []; diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index c5eca20a048..8465b57897e 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -277,6 +277,125 @@ test("collision npubs sit inline with agent metadata", async () => { } }); +function describedByText(button) { + return (button.getAttribute("aria-describedby") ?? "") + .split(" ") + .filter(Boolean) + .map((id) => document.getElementById(id)?.textContent ?? `missing:${id}`); +} + +test("agent rows show their public description on one line", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [ + { + pubkey: "a".repeat(64), + displayName: "Reset Worker", + isAgent: true, + ownerLabel: "Russell", + description: "Builds your weekly plan. Ask for a check-in.", + audienceNote: null, + }, + ], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + const description = view.getByTestId("mention-agent-description"); + assert.equal( + description.textContent, + "Builds your weekly plan. Ask for a check-in.", + ); + assert.match(description.className, /(?:^|\s)truncate(?:\s|$)/); + assert.equal( + description.getAttribute("title"), + "Builds your weekly plan. Ask for a check-in.", + ); + assert.ok(view.getByText("managed by Russell")); + assert.equal(view.queryByTestId("mention-agent-audience-note"), null); + const button = view.getByRole("button", { name: "Mention Reset Worker" }); + assert.deepEqual(describedByText(button), [ + "Builds your weekly plan. Ask for a check-in.", + ]); +}); + +test("an agent that will not answer the person typing names who it answers", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [ + { + pubkey: "a".repeat(64), + displayName: "Reset Worker", + isAgent: true, + ownerLabel: "Russell", + notInChannel: true, + description: "Builds your weekly plan.", + audienceNote: "Answers only Russell", + }, + { + pubkey: "b".repeat(64), + displayName: "Helper", + isAgent: true, + audienceNote: "Answers only Russell", + }, + ], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + const notes = view.getAllByTestId("mention-agent-audience-note"); + assert.deepEqual( + notes.map((note) => note.textContent), + ["Answers only Russell · not in channel", "Answers only Russell"], + ); + assert.equal(view.queryByText(/managed by/), null); + assert.deepEqual( + describedByText(view.getByRole("button", { name: "Mention Reset Worker" })), + ["Answers only Russell · not in channel", "Builds your weekly plan."], + ); + assert.deepEqual( + describedByText(view.getByRole("button", { name: "Mention Helper" })), + ["Answers only Russell"], + ); +}); + +test("people rows never carry agent details", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [ + { + pubkey: "a".repeat(64), + displayName: "Craig", + description: "Not an agent.", + audienceNote: "Answers only Russell", + }, + ], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + assert.equal(view.queryByTestId("mention-agent-description"), null); + assert.equal(view.queryByTestId("mention-agent-audience-note"), null); + assert.equal( + view + .getByRole("button", { name: "Mention Craig" }) + .getAttribute("aria-describedby"), + null, + ); +}); + test("does not intercept Tab from the editor", async () => { const React = await import("react"); const { fireEvent, render } = await import("@testing-library/react"); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 65ae331d594..24821a85301 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -31,6 +31,10 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + /** The agent's public description (profile About), on one line. */ + description?: string | null; + /** "Answers only " when the agent will not answer the person typing. */ + audienceNote?: string | null; }; type MentionAutocompleteProps = { @@ -87,6 +91,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ const optionsSurfaceRef = React.useRef(null); const listRef = React.useRef(null); const keepPinnedSwitchId = React.useId(); + const rowDetailsIdPrefix = React.useId(); const handledOptionsRequestRef = React.useRef(0); const alwaysAddressShortcut = getPlatformKeysById("always-address-agent"); @@ -255,20 +260,46 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; const showAgentProvenanceMarker = showMentionAgentProvenanceMarker(suggestion); + const audienceNote = suggestion.isAgent + ? (suggestion.audienceNote ?? null) + : null; + const description = suggestion.isAgent + ? (suggestion.description ?? null) + : null; + const noteId = audienceNote + ? `${rowDetailsIdPrefix}-${index}-note` + : undefined; + const descriptionId = description + ? `${rowDetailsIdPrefix}-${index}-description` + : undefined; + const detailsIds = + [noteId, descriptionId].filter(Boolean).join(" ") || undefined; + // The audience note names the owner, so it takes the place of + // "managed by ". const ownerLabel = - hasNameCollision && suggestion.agentProvenance + audienceNote || (hasNameCollision && suggestion.agentProvenance) ? null : suggestion.ownerLabel; const collisionNpub = hasNameCollision && suggestion.pubkey ? safeNpub(suggestion.pubkey) : null; + const metadataLabel = + audienceNote && suggestion.notInChannel + ? `${audienceNote} · not in channel` + : (audienceNote ?? + (ownerLabel && suggestion.notInChannel + ? `managed by ${ownerLabel} · not in channel` + : ownerLabel + ? `managed by ${ownerLabel}` + : suggestion.notInChannel + ? "not in channel" + : null)); const hasMetadataBeforeNpub = Boolean( suggestion.kind === "team" || suggestion.isAgent || suggestion.role || - ownerLabel || - suggestion.notInChannel, + metadataLabel, ); const canAlwaysAddress = Boolean( onToggleAlwaysAddressAgent && @@ -293,6 +324,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ key={suggestionKey} > {canAlwaysAddress ? ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 75b7d0d3391..24f28650a35 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { AlertTriangle } from "lucide-react"; +import { Globe } from "lucide-react"; import { depthGuideActionsEqual, numberArrayEqual, @@ -504,9 +504,10 @@ export const MessageRow = React.memo( } > {message.respondTo === "anyone" ? ( -

- Manage who has access to this relay. + Manage who has access to this community server.

diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 29543328fac..e5b978dc2ba 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -147,17 +147,17 @@ function feedHeadline(item: FeedItem, groupItems: readonly FeedItem[] = []) { case 40007: return "Reminder"; case 43001: - return "Job requested"; + return "Task requested"; case 43002: - return "Job accepted"; + return "Task accepted"; case 43003: return "Progress update"; case 43004: - return "Job result"; + return "Task result"; case 43005: - return "Job cancelled"; + return "Task cancelled"; case 43006: - return "Job failed"; + return "Task failed"; case 45001: return "Forum post"; case 45003: diff --git a/desktop/src/features/home/lib/inboxFilterOptions.test.mjs b/desktop/src/features/home/lib/inboxFilterOptions.test.mjs new file mode 100644 index 00000000000..945e1bb1943 --- /dev/null +++ b/desktop/src/features/home/lib/inboxFilterOptions.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + INBOX_FILTER_OPTIONS, + visibleInboxFilterOptions, +} from "./inboxFilterOptions.ts"; + +test("the Projects filter shows only while Projects is on", () => { + assert.deepEqual( + visibleInboxFilterOptions(true).map((option) => option.label), + [ + "All", + "Projects", + "Mentions", + "Threads", + "Needs action", + "Agents", + "Reminders", + "Drafts", + ], + ); + assert.deepEqual( + visibleInboxFilterOptions(false).map((option) => option.value), + INBOX_FILTER_OPTIONS.map((option) => option.value).filter( + (value) => value !== "project", + ), + ); +}); diff --git a/desktop/src/features/home/lib/inboxFilterOptions.ts b/desktop/src/features/home/lib/inboxFilterOptions.ts new file mode 100644 index 00000000000..b93bc78fdaf --- /dev/null +++ b/desktop/src/features/home/lib/inboxFilterOptions.ts @@ -0,0 +1,29 @@ +import type { InboxFilter } from "./inbox"; + +export type InboxFilterOption = { + label: string; + value: InboxFilter; +}; + +export const INBOX_FILTER_OPTIONS: readonly InboxFilterOption[] = [ + { value: "all", label: "All" }, + { value: "project", label: "Projects" }, + { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, + { value: "needs_action", label: "Needs action" }, + { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, + { value: "drafts", label: "Drafts" }, +]; + +/** + * The filters the inbox menu offers. Projects is a preview feature, so its + * filter appears only while Projects is turned on. + */ +export function visibleInboxFilterOptions( + projectsEnabled: boolean, +): readonly InboxFilterOption[] { + return projectsEnabled + ? INBOX_FILTER_OPTIONS + : INBOX_FILTER_OPTIONS.filter((option) => option.value !== "project"); +} diff --git a/desktop/src/features/home/ui/FeedSection.tsx b/desktop/src/features/home/ui/FeedSection.tsx index 2dafc4e9fbe..18a7c589fce 100644 --- a/desktop/src/features/home/ui/FeedSection.tsx +++ b/desktop/src/features/home/ui/FeedSection.tsx @@ -63,17 +63,17 @@ function feedHeadline(item: FeedItem) { case KIND_REMINDER: return "Reminder"; case KIND_JOB_REQUEST: - return "Job requested"; + return "Task requested"; case KIND_JOB_ACCEPTED: - return "Job accepted"; + return "Task accepted"; case KIND_JOB_PROGRESS: return "Progress update"; case KIND_JOB_RESULT: - return "Job result"; + return "Task result"; case KIND_JOB_CANCEL: - return "Job cancelled"; + return "Task cancelled"; case KIND_JOB_ERROR: - return "Job failed"; + return "Task failed"; case KIND_FORUM_POST: return "Forum post"; case KIND_FORUM_COMMENT: diff --git a/desktop/src/features/home/ui/InboxFilterMenu.tsx b/desktop/src/features/home/ui/InboxFilterMenu.tsx index 6fcc759731b..5185e606dfb 100644 --- a/desktop/src/features/home/ui/InboxFilterMenu.tsx +++ b/desktop/src/features/home/ui/InboxFilterMenu.tsx @@ -1,6 +1,11 @@ import { ChevronDown } from "lucide-react"; import type { InboxFilter } from "@/features/home/lib/inbox"; +import { + INBOX_FILTER_OPTIONS, + visibleInboxFilterOptions, +} from "@/features/home/lib/inboxFilterOptions"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { DropdownMenu, @@ -11,20 +16,6 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -const INBOX_FILTER_OPTIONS: Array<{ - label: string; - value: InboxFilter; -}> = [ - { value: "all", label: "All" }, - { value: "project", label: "Projects" }, - { value: "mention", label: "Mentions" }, - { value: "thread", label: "Threads" }, - { value: "needs_action", label: "Needs action" }, - { value: "agent_activity", label: "Agents" }, - { value: "reminders", label: "Reminders" }, - { value: "drafts", label: "Drafts" }, -]; - const TRIGGER_CLASS = "inline-flex h-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 relative -ml-2 w-auto gap-1 px-2 text-sm font-medium text-foreground"; @@ -43,6 +34,8 @@ export function InboxFilterMenu({ onFilterChange, reminderCount, }: InboxFilterMenuProps) { + const projectsEnabled = useFeatureEnabled("projects"); + const filterOptions = visibleInboxFilterOptions(projectsEnabled); const activeFilter = INBOX_FILTER_OPTIONS.find( (option) => option.value === filter, ); @@ -71,7 +64,7 @@ export function InboxFilterMenu({ onValueChange={(value) => onFilterChange(value as InboxFilter)} value={filter} > - {INBOX_FILTER_OPTIONS.map((option) => ( + {filterOptions.map((option) => (
{option.value === "reminders" ? ( diff --git a/desktop/src/features/notifications/lib/sound.ts b/desktop/src/features/notifications/lib/sound.ts index 3371658a3bd..aef537fb267 100644 --- a/desktop/src/features/notifications/lib/sound.ts +++ b/desktop/src/features/notifications/lib/sound.ts @@ -39,10 +39,10 @@ export const SLOT_LABELS: Record = { mention: "@Mentions", thread_reply: "Thread replies", needs_action: "Needs action", - job_accepted: "Agent: job accepted", + job_accepted: "Agent: task accepted", job_progress: "Agent: progress update", - job_result: "Agent: job result", - job_error: "Agent: job error", + job_result: "Agent: task result", + job_error: "Agent: task error", }; // The agent job protocol (kinds 43001-43006) is defined and queryable but @@ -61,10 +61,10 @@ export const SLOT_DESCRIPTIONS: Record = { mention: "When someone tags you in a channel.", thread_reply: "When someone replies in a thread you follow or posted in.", needs_action: "When an approval or reminder is waiting on you.", - job_accepted: "When an agent picks up a job.", - job_progress: "While an agent works through a job.", - job_result: "When an agent finishes a job.", - job_error: "When an agent job fails.", + job_accepted: "When an agent picks up a task.", + job_progress: "While an agent works through a task.", + job_result: "When an agent finishes a task.", + job_error: "When an agent task fails.", }; export const RECOMMENDED_SOUND_BY_SLOT: Record = { diff --git a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx index 3224a67ef3f..b2978495cbc 100644 --- a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx +++ b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx @@ -4,6 +4,8 @@ import { toggleTerminalPanel, useTerminalPanel, } from "@/features/terminal/terminalPanelStore"; +import { terminalShortcutLabel } from "@/features/terminal/terminalShortcut"; +import { TERMINAL_LABEL } from "@/shared/constants/brand"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; @@ -88,7 +90,7 @@ export function ProjectRightPanelControls({ disabled={!terminalAvailable} onClick={toggleTerminalPanel} size="icon" - title="Dreamforge Term (⌘J)" + title={`${TERMINAL_LABEL} (${terminalShortcutLabel()})`} type="button" variant="ghost" > diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index cc373ca7fb6..ba9629fa82b 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -166,7 +166,7 @@ function describeSearchHit(hit: SearchHit) { case 45003: return "Forum reply"; case 43001: - return "Agent job"; + return "Agent task"; case 43003: return "Agent update"; case 46010: diff --git a/desktop/src/features/sidebar/ui/SidebarRelayConnectionCard.tsx b/desktop/src/features/sidebar/ui/SidebarRelayConnectionCard.tsx index ab1cc360e02..4771e297ce5 100644 --- a/desktop/src/features/sidebar/ui/SidebarRelayConnectionCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarRelayConnectionCard.tsx @@ -69,7 +69,9 @@ export function SidebarRelayConnectionCompactCard({ return ( diff --git a/desktop/src/features/terminal/terminalShortcut.test.mjs b/desktop/src/features/terminal/terminalShortcut.test.mjs new file mode 100644 index 00000000000..70d089a0b46 --- /dev/null +++ b/desktop/src/features/terminal/terminalShortcut.test.mjs @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { terminalShortcutLabel } from "./terminalShortcut.ts"; + +test("the terminal shortcut reads ⌘J on a Mac and Ctrl+J elsewhere", () => { + assert.equal(terminalShortcutLabel(true), "⌘J"); + assert.equal(terminalShortcutLabel(false), "Ctrl+J"); +}); diff --git a/desktop/src/features/terminal/terminalShortcut.ts b/desktop/src/features/terminal/terminalShortcut.ts new file mode 100644 index 00000000000..937dce12c92 --- /dev/null +++ b/desktop/src/features/terminal/terminalShortcut.ts @@ -0,0 +1,6 @@ +import { isMacPlatform } from "@/shared/lib/platform"; + +/** The keys that open and close the terminal, as this platform writes them. */ +export function terminalShortcutLabel(isMac = isMacPlatform()): string { + return isMac ? "⌘J" : "Ctrl+J"; +} diff --git a/desktop/src/shared/lib/relayError.ts b/desktop/src/shared/lib/relayError.ts index 285a8651fd7..14082d0803a 100644 --- a/desktop/src/shared/lib/relayError.ts +++ b/desktop/src/shared/lib/relayError.ts @@ -12,9 +12,9 @@ */ const RELAY_UNREACHABLE_PREFIX = "relay unreachable:"; -export const RELAY_UNREACHABLE_SHORT = "Can't reach the relay."; +export const RELAY_UNREACHABLE_SHORT = "Can't reach the community server."; export const RELAY_UNREACHABLE_MESSAGE = - "Can't reach the relay — check your VPN or network connection."; + "Can't reach the community server — check your VPN or network connection."; /** * Returns true when `error` carries the stable Rust-layer prefix indicating diff --git a/desktop/tests/e2e/inbox-filter-projects.spec.ts b/desktop/tests/e2e/inbox-filter-projects.spec.ts new file mode 100644 index 00000000000..68a4a74c444 --- /dev/null +++ b/desktop/tests/e2e/inbox-filter-projects.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test("the Projects inbox filter is hidden while Projects is off", async ({ + page, +}) => { + await installMockBridge(page, undefined, { seedPreviewFeatures: false }); + await page.goto("/"); + + await page.getByTestId("inbox-filter-trigger").click(); + await expect( + page.getByRole("menuitemradio", { name: "Mentions" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitemradio", { name: "Projects" }), + ).toHaveCount(0); +}); + +test("the Projects inbox filter is offered while Projects is on", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("inbox-filter-trigger").click(); + await expect( + page.getByRole("menuitemradio", { name: "Projects" }), + ).toBeVisible(); +}); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 3885d70941a..9ea81f450f2 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3843,7 +3843,7 @@ test("generic relay save failures use the generic reconnect card", async ({ ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), - ).toContainText("Can't reach the relay"); + ).toContainText("Can't reach the community server"); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toContainText("Click to connect"); @@ -3871,7 +3871,7 @@ test("custom relay proxy sign-in failures use the generic reconnect card", async ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), - ).toContainText("Can't reach the relay"); + ).toContainText("Can't reach the community server"); }); test("community access failures use the generic reconnect card", async ({ @@ -3895,7 +3895,7 @@ test("community access failures use the generic reconnect card", async ({ ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), - ).toContainText("Can't reach the relay"); + ).toContainText("Can't reach the community server"); }); test("dismissed relay save failures reappear on retry", async ({ page }) => { @@ -4241,7 +4241,7 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses", const card = page.getByTestId("onboarding-relay-reconnect-card"); await expect(card).toBeVisible(); - await expect(card).toContainText("Can't reach the relay"); + await expect(card).toContainText("Can't reach the community server"); // Drive degraded before clicking so the card is in the expected error state. await setRelayConnectionState(page, "disconnected"); @@ -4257,7 +4257,7 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses", await setRelayConnectionState(page, "connected"); await expect(card).toContainText("Connected", { timeout: 5_000 }); - await expect(card).not.toContainText("Can't reach the relay"); + await expect(card).not.toContainText("Can't reach the community server"); // Auto-dismiss fires after ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS // (2500ms). Allow generous headroom for CI. @@ -4283,7 +4283,7 @@ test("onboarding relay reconnect — dismiss is clickable at minimum size", asyn const card = page.getByTestId("onboarding-relay-reconnect-card"); await expect(card).toBeVisible(); await page - .getByRole("button", { name: "Dismiss relay notification" }) + .getByRole("button", { name: "Dismiss community server notification" }) .click(); await expect(card).toHaveCount(0); }); @@ -4322,7 +4322,7 @@ test("onboarding relay reconnect — connected without a prior click does not sh // card stayed in the error state. await page.waitForTimeout(500); await expect(card).toBeVisible(); - await expect(card).toContainText("Can't reach the relay"); + await expect(card).toContainText("Can't reach the community server"); await expect(card).not.toContainText("Connected"); }); diff --git a/desktop/tests/e2e/relay-connectivity.spec.ts b/desktop/tests/e2e/relay-connectivity.spec.ts index dbe2b5d77c2..34021af0e49 100644 --- a/desktop/tests/e2e/relay-connectivity.spec.ts +++ b/desktop/tests/e2e/relay-connectivity.spec.ts @@ -103,7 +103,7 @@ test.describe("relay connectivity", () => { await expect(relayCard).toBeVisible({ timeout: 5_000, }); - await expect(relayCard).toContainText("Can't reach the relay"); + await expect(relayCard).toContainText("Can't reach the community server"); await expect(relayCard).toContainText("Click to connect"); await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); await settle(page); @@ -124,7 +124,7 @@ test.describe("relay connectivity", () => { await expect(relayCard).toBeVisible({ timeout: 5_000, }); - await expect(relayCard).toContainText("Can't reach the relay"); + await expect(relayCard).toContainText("Can't reach the community server"); await expect(relayCard).toContainText("Click to connect"); await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); await settle(page); @@ -147,7 +147,7 @@ test.describe("relay connectivity", () => { const canvasSection = page.getByTestId("channel-canvas-section"); await canvasSection.scrollIntoViewIfNeeded(); await expect( - canvasSection.getByText("Can't reach the relay."), + canvasSection.getByText("Can't reach the community server."), ).toBeVisible(); // Await Radix sheet animations before measuring the settled state. @@ -218,7 +218,7 @@ test.describe("relay connectivity", () => { await expect(relayCard).toBeVisible({ timeout: 5_000, }); - await expect(relayCard).toContainText("Can't reach the relay"); + await expect(relayCard).toContainText("Can't reach the community server"); await expect(relayCard).toContainText("Click to connect"); await driveConnectionDegraded(page, "connected"); diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index b9903614d2c..0a95489e523 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -106,7 +106,7 @@ async function emitRelayConnectionState( async function expectGenericReconnectCard(page: Page) { const card = page.getByTestId("sidebar-relay-unreachable"); await expect(card).toBeVisible(); - await expect(card).toContainText("Can't reach the relay"); + await expect(card).toContainText("Can't reach the community server"); await expect(card).toContainText("Click to connect"); await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); return card; @@ -137,7 +137,7 @@ test("relay outage notification stays dismissed through retries and re-arms afte const card = await expectGenericReconnectCard(page); await card - .getByRole("button", { name: "Dismiss relay notification" }) + .getByRole("button", { name: "Dismiss community server notification" }) .click({ force: true }); await expect(card).toBeHidden(); @@ -165,7 +165,7 @@ test("relay outage notification re-arms after same-URL lifecycle teardown", asyn const card = await expectGenericReconnectCard(page); await card - .getByRole("button", { name: "Dismiss relay notification" }) + .getByRole("button", { name: "Dismiss community server notification" }) .click({ force: true }); await expect(card).toBeHidden(); @@ -221,7 +221,7 @@ test("collapsed sidebar still shows the reconnect card", async ({ page }) => { // The card remains visible via the fixed overlay even with sidebar collapsed. const card = page.getByTestId("sidebar-relay-unreachable"); await expect(card).toBeVisible(); - await expect(card).toContainText("Can't reach the relay"); + await expect(card).toContainText("Can't reach the community server"); await setChannelsReadError(page, null); await page.getByTestId("sidebar-reconnect").click(); @@ -299,7 +299,7 @@ test("sidebar reconnect action suppresses stale refresh errors after success", a await page.waitForTimeout(500); await expect(card).toBeVisible(); await expect(card).toContainText("Connected"); - await expect(card).not.toContainText("Can't reach the relay"); + await expect(card).not.toContainText("Can't reach the community server"); await page.waitForTimeout(6_500); await expect(card).toBeHidden(); @@ -326,7 +326,7 @@ test("sidebar connected success clears when relay degrades again", async ({ await setRelayConnectionState(page, "stalled"); - await expect(card).toContainText("Can't reach the relay", { + await expect(card).toContainText("Can't reach the community server", { timeout: 10_000, }); await expect(card).toContainText("Click to connect"); From c9097a9409ec58fda3135482ac21c23465435f12 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 14:46:06 -0500 Subject: [PATCH 22/28] fix: guest mode review Fixes from reviewing guest mode against the guest journey. - A guest with a new key now joins the community's #general and #welcome-everyone during entry, the way Block's phone app does after an invite. Accepting an invite adds a person to the community but to none of its channels, and guest mode skipped the only desktop code that joined them, so a new guest landed in an empty Inbox. Guest entry joins the owner's existing open starter channels (it creates nothing), adds them to the channel list in case the relay's member list trails the join, and lands the guest in the owner's channel for them, else #welcome-everyone. The join has its own time budget. If it fails or times out, the app tries again each time it opens the community until it succeeds. - The guest hint "Type @ and an agent's name to ask it for something." now shows only where the @ list offers an agent that answers the guest, by the same check the list uses, and not before both agent lists have loaded. It used to show whenever the community had any agent. It now shows in any channel where that holds, not only #welcome-everyone, until the guest dismisses it or asks an agent, and that is remembered per community. - A saved guest flag now corrects itself: when the relay lists the person as the community's owner or admin, the flag is cleared and they get the normal setup. Guest entry decides on the invite alone when the role lookup does not answer, so an owner trying an invite link on a slow relay stayed a guest for good. On a relay with no member list nobody's role is known, so the invite still decides there. - Next on Build your profile reads "Opening your community" while guest entry waits on the relay, and the form is locked while an existing profile enters by itself. - An invite redeemed on the membership-denied screen is now claimed. A new code for the same relay sends onboarding back to claiming, without the old code's join-policy receipt, and the denied screen closes. Before, the code was stored but never claimed, and guest mode trusted it. - An invite link opened while the AI-provider step is showing skips the rest of it, as one opened earlier does. The config step is left alone so a save in progress finishes. - About no longer opens by itself on a project's home channel, whose canvas starts as a table of command-line commands. The button stays. - Copy: the profile field is "Your name", the profile text no longer mentions agent handoffs, and the membership-denied screen says community server and community admin instead of relay. Unit tests cover the starter join (what is joined, that nothing is created, a failed join), its retry record, the landing, the merged channel list, clearing the guest flag, the hint rule and the same-relay invite rule. New E2E cases cover a new key joining both starter channels and landing in #welcome-everyone, a failed join retried when the app opens, the waiting Next button and locked form, the AI-provider skip, no hint for an agent outside the channel or one that answers only its owner, the hint in the guest's own channel until dismissed or used, owner and admin flags clearing while a member's stays, the membership-denied invite being claimed, and a project home not opening About. Reverting each fix fails its E2E case. The mock bridge can now seed a canvas on any channel and queue a community link while the app runs. Co-Authored-By: Claude Opus 5.5 --- .../channels/lib/channelAbout.test.mjs | 15 +- .../src/features/channels/lib/channelAbout.ts | 8 +- .../channels/lib/welcomeComposerHint.test.mjs | 49 ++- .../channels/lib/welcomeComposerHint.ts | 35 +- .../src/features/channels/ui/ChannelPane.tsx | 41 +- .../features/channels/ui/useChannelAbout.ts | 16 +- .../channels/ui/useHasMentionableAgents.ts | 48 +++ .../src/features/community-members/hooks.ts | 3 +- .../onboarding/communityOnboarding.test.mjs | 50 ++- .../onboarding/communityOnboarding.tsx | 17 +- desktop/src/features/onboarding/guestEntry.ts | 88 ++++- .../features/onboarding/guestMode.test.mjs | 370 ++++++++++++++++-- desktop/src/features/onboarding/guestMode.ts | 176 ++++++++- desktop/src/features/onboarding/hooks.ts | 49 ++- .../onboarding/ui/CommunityOnboardingFlow.tsx | 42 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 11 + .../onboarding/ui/MembershipDenied.tsx | 8 +- .../onboarding/useIsCommunityGuest.ts | 77 +++- desktop/src/testing/e2eBridge.ts | 39 +- desktop/tests/e2e/channel-about.spec.ts | 32 ++ desktop/tests/e2e/deep-link-invite.spec.ts | 364 +++++++++++++++-- desktop/tests/e2e/onboarding.spec.ts | 80 +++- desktop/tests/helpers/bridge.ts | 2 + 23 files changed, 1468 insertions(+), 152 deletions(-) create mode 100644 desktop/src/features/channels/ui/useHasMentionableAgents.ts diff --git a/desktop/src/features/channels/lib/channelAbout.test.mjs b/desktop/src/features/channels/lib/channelAbout.test.mjs index e9d1d4f54bc..3db03045420 100644 --- a/desktop/src/features/channels/lib/channelAbout.test.mjs +++ b/desktop/src/features/channels/lib/channelAbout.test.mjs @@ -49,15 +49,18 @@ test("only a canvas with visible text counts as content", () => { assert.equal(hasChannelCanvasContent({ content: "# Start here" }), true); }); -test("auto-open needs content, a first visit and a free side panel", () => { +test("auto-open needs content, a first visit, a free side panel and no project home", () => { for (const hasContent of [false, true]) { for (const seen of [false, true]) { for (const otherPanelOpen of [false, true]) { - assert.equal( - shouldAutoOpenChannelAbout({ hasContent, otherPanelOpen, seen }), - hasContent && !seen && !otherPanelOpen, - JSON.stringify({ hasContent, otherPanelOpen, seen }), - ); + for (const isProjectHome of [false, true]) { + const input = { hasContent, isProjectHome, otherPanelOpen, seen }; + assert.equal( + shouldAutoOpenChannelAbout(input), + hasContent && !isProjectHome && !seen && !otherPanelOpen, + JSON.stringify(input), + ); + } } } } diff --git a/desktop/src/features/channels/lib/channelAbout.ts b/desktop/src/features/channels/lib/channelAbout.ts index 62fac65b29d..4b38284134a 100644 --- a/desktop/src/features/channels/lib/channelAbout.ts +++ b/desktop/src/features/channels/lib/channelAbout.ts @@ -58,16 +58,20 @@ export function markChannelAboutSeen(channelId: string) { * Whether to open the About panel on its own for this visit: the canvas has * content, this device has not shown it before, and nothing else (a thread, * a profile, channel settings, agent activity) already holds the side panel, - * so a link that opened a thread is never covered. + * so a link that opened a thread is never covered. A project's home channel + * never opens it: its canvas starts as a table of command-line commands for + * agents, not an introduction for people. */ export function shouldAutoOpenChannelAbout({ hasContent, + isProjectHome, otherPanelOpen, seen, }: { hasContent: boolean; + isProjectHome: boolean; otherPanelOpen: boolean; seen: boolean; }) { - return hasContent && !seen && !otherPanelOpen; + return hasContent && !isProjectHome && !seen && !otherPanelOpen; } diff --git a/desktop/src/features/channels/lib/welcomeComposerHint.test.mjs b/desktop/src/features/channels/lib/welcomeComposerHint.test.mjs index e32abb415f7..2e27b880a97 100644 --- a/desktop/src/features/channels/lib/welcomeComposerHint.test.mjs +++ b/desktop/src/features/channels/lib/welcomeComposerHint.test.mjs @@ -6,18 +6,49 @@ import { resolveWelcomeComposerHint, } from "./welcomeComposerHint.ts"; -test("resolveWelcomeComposerHint points guests at agents only when they can see some", () => { +const BASE = { + channelType: "stream", + guestHintDone: false, + hasMentionableAgents: true, + isGuest: false, + isWelcomeChannel: true, +}; + +test("people with the Welcome Team are pointed at @Fizz in the welcome channels only", () => { + assert.equal(resolveWelcomeComposerHint(BASE), "welcome-team"); + assert.equal( + resolveWelcomeComposerHint({ ...BASE, hasMentionableAgents: false }), + "welcome-team", + ); + assert.equal( + resolveWelcomeComposerHint({ ...BASE, isWelcomeChannel: false }), + null, + ); +}); + +test("a guest is told how to ask agents in any channel where one answers them", () => { + const guest = { ...BASE, isGuest: true }; + assert.equal(resolveWelcomeComposerHint(guest), "guest-agents"); + assert.equal( + resolveWelcomeComposerHint({ ...guest, isWelcomeChannel: false }), + "guest-agents", + ); +}); + +test("a guest gets no hint where no agent answers, outside channels, or once done", () => { + const guest = { ...BASE, isGuest: true, isWelcomeChannel: false }; const cases = [ - { isGuest: false, hasVisibleAgents: false, expected: "welcome-team" }, - { isGuest: false, hasVisibleAgents: true, expected: "welcome-team" }, - { isGuest: true, hasVisibleAgents: true, expected: "guest-agents" }, - { isGuest: true, hasVisibleAgents: false, expected: null }, + { hasMentionableAgents: false }, + { channelType: "dm" }, + { channelType: "forum" }, + { channelType: null }, + { guestHintDone: true }, ]; - for (const { expected, ...input } of cases) { + for (const overrides of cases) { assert.equal( - resolveWelcomeComposerHint(input), - expected, - JSON.stringify(input), + resolveWelcomeComposerHint({ ...guest, ...overrides }), + null, + JSON.stringify(overrides), ); } }); diff --git a/desktop/src/features/channels/lib/welcomeComposerHint.ts b/desktop/src/features/channels/lib/welcomeComposerHint.ts index d3df5ff85f0..7ee85f5e072 100644 --- a/desktop/src/features/channels/lib/welcomeComposerHint.ts +++ b/desktop/src/features/channels/lib/welcomeComposerHint.ts @@ -1,27 +1,38 @@ -/** What a guest reads over the composer in #welcome-everyone. */ +import type { ChannelType } from "@/shared/api/types"; + +/** What a guest reads over the composer where an agent will answer them. */ export const GUEST_AGENT_MENTION_HINT = "Type @ and an agent's name to ask it for something."; /** - * The hint over the composer in the welcome channels. "welcome-team" points - * at @Fizz and the rest of the Welcome Team; "guest-agents" tells a guest how - * to ask the community's agents. + * The hint over the composer. "welcome-team" points at @Fizz and the rest of + * the Welcome Team; "guest-agents" tells a guest how to ask the community's + * agents. */ export type WelcomeComposerHint = "welcome-team" | "guest-agents"; /** - * Which hint the welcome channels show above the composer, or null for none. - * People who got the Welcome Team are pointed at @Fizz. Guests have no - * Welcome Team, so they are told how to ask the community's agents when they - * can see any, and see no hint otherwise. + * Which hint shows above the composer, or null for none. People who got the + * Welcome Team are pointed at @Fizz in the welcome channels. Guests have no + * Welcome Team: in any channel where the @ list offers them an agent that + * answers them, they are told how to ask it, until they have used the hint + * or dismissed it. Where no agent would answer, they see no hint. */ export function resolveWelcomeComposerHint({ - hasVisibleAgents, + channelType, + guestHintDone, + hasMentionableAgents, isGuest, + isWelcomeChannel, }: { - hasVisibleAgents: boolean; + channelType: ChannelType | null; + guestHintDone: boolean; + hasMentionableAgents: boolean; isGuest: boolean; + isWelcomeChannel: boolean; }): WelcomeComposerHint | null { - if (!isGuest) return "welcome-team"; - return hasVisibleAgents ? "guest-agents" : null; + if (!isGuest) return isWelcomeChannel ? "welcome-team" : null; + return channelType === "stream" && hasMentionableAgents && !guestHintDone + ? "guest-agents" + : null; } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index b6fba18bfc4..7dbc00dd897 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -69,7 +69,8 @@ import { useRoutedMessageEdit } from "@/features/channels/ui/useRoutedMessageEdi import { Button } from "@/shared/ui/button"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import { resolveWelcomeComposerHint } from "@/features/channels/lib/welcomeComposerHint"; -import { useIsCommunityGuest } from "@/features/onboarding/useIsCommunityGuest"; +import { useHasMentionableAgents } from "@/features/channels/ui/useHasMentionableAgents"; +import { useCommunityGuest } from "@/features/onboarding/useIsCommunityGuest"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -236,13 +237,22 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const huddleMemberPubkeysPending = agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); - const isActiveWelcomeChannel = - activeChannel !== null && isWelcomeExperience(activeChannel); - const isCommunityGuest = useIsCommunityGuest(); + const communityGuest = useCommunityGuest(); + const hasMentionableAgents = useHasMentionableAgents( + activeChannel, + currentPubkey, + ); const welcomeComposerHint = resolveWelcomeComposerHint({ - hasVisibleAgents: (agentPubkeys?.size ?? 0) > 0, - isGuest: isCommunityGuest, + channelType: activeChannel?.channelType ?? null, + guestHintDone: communityGuest.agentHintDone, + hasMentionableAgents, + isGuest: communityGuest.isGuest, + isWelcomeChannel: + activeChannel !== null && isWelcomeExperience(activeChannel), }); + // This channel shows a composer hint: the Welcome Team's in the welcome + // channels, or a guest's wherever an agent answers them. + const isActiveWelcomeChannel = welcomeComposerHint !== null; useComposerHeightPadding( timelineScrollRef, composerWrapperRef, @@ -252,13 +262,24 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const { bannerState: welcomeComposerBannerState, - completeBanner: completeWelcomeComposerBanner, - dismissBanner: handleDismissWelcomeBanner, + completeBanner, + dismissBanner, } = useWelcomeComposerBanner( activeChannelId, isActiveWelcomeChannel, currentPubkey ?? null, ); + // A guest's hint, once used or dismissed, stays gone in this community. + const { markAgentHintDone } = communityGuest; + const isGuestAgentHint = welcomeComposerHint === "guest-agents"; + const completeWelcomeComposerBanner = React.useCallback(() => { + completeBanner(); + if (isGuestAgentHint) markAgentHintDone(); + }, [completeBanner, isGuestAgentHint, markAgentHintDone]); + const handleDismissWelcomeBanner = React.useCallback(() => { + dismissBanner(); + if (isGuestAgentHint) markAgentHintDone(); + }, [dismissBanner, isGuestAgentHint, markAgentHintDone]); const isEditInThread = editTarget?.isThreadReply === true; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; @@ -747,9 +768,7 @@ export const ChannelPane = React.memo(function ChannelPane({ hasComposerBottomActivity && "composer-dock--with-activity", )} > - {isActiveWelcomeChannel && - welcomeComposerHint && - !timeoutActive ? ( + {welcomeComposerHint && !timeoutActive ? ( (null); React.useEffect(() => { - if (channelId === null || !canvasLoaded) return; + if (channelId === null || !canvasLoaded || !projectsAnswered) return; if (decidedChannelIdRef.current === channelId) return; decidedChannelIdRef.current = channelId; if (channelAboutOpen) { @@ -72,6 +83,7 @@ export function useChannelAbout({ !canOpenBesideChannel || !shouldAutoOpenChannelAbout({ hasContent: hasChannelAbout, + isProjectHome, otherPanelOpen, seen: hasSeenChannelAbout(channelId), }) @@ -87,7 +99,9 @@ export function useChannelAbout({ channelAboutOpen, channelId, hasChannelAbout, + isProjectHome, otherPanelOpen, + projectsAnswered, setChannelAboutOpen, ]); diff --git a/desktop/src/features/channels/ui/useHasMentionableAgents.ts b/desktop/src/features/channels/ui/useHasMentionableAgents.ts new file mode 100644 index 00000000000..864008e7beb --- /dev/null +++ b/desktop/src/features/channels/ui/useHasMentionableAgents.ts @@ -0,0 +1,48 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { + getMentionableAgentPubkeys, + isAgentDirectoryReady, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import type { Channel } from "@/shared/api/types"; + +/** + * Whether the @ list in this stream channel offers the viewer at least one + * agent that answers them, by the same check the mention picker uses: their + * own agents, plus other people's agents that are members of this channel and + * whose reply setting includes the viewer. False until both agent directories + * have loaded, so a hint never promises an agent the list won't show. + */ +export function useHasMentionableAgents( + channel: Channel | null, + currentPubkey: string | null | undefined, +): boolean { + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + const directoriesReady = + isAgentDirectoryReady(managedAgentsQuery) && + isAgentDirectoryReady(relayAgentsQuery); + const channelId = channel?.channelType === "stream" ? channel.id : null; + const managedAgents = managedAgentsQuery.data; + const relayAgents = relayAgentsQuery.data; + + return React.useMemo(() => { + if (!directoriesReady || !channelId) return false; + return ( + getMentionableAgentPubkeys({ + currentPubkey, + phase: "prepare", + eligibilityScope: { type: "channel", channelId }, + managedAgentPubkeys: (managedAgents ?? []).map((agent) => agent.pubkey), + relayAgents, + // In a channel, whether an agent answers depends on that channel + // alone, so the viewer's other channels don't change the answer. + sharedChannelIds: new Set([channelId]), + }).size > 0 + ); + }, [channelId, currentPubkey, directoriesReady, managedAgents, relayAgents]); +} diff --git a/desktop/src/features/community-members/hooks.ts b/desktop/src/features/community-members/hooks.ts index bb63a888d98..2f95c1aa9fd 100644 --- a/desktop/src/features/community-members/hooks.ts +++ b/desktop/src/features/community-members/hooks.ts @@ -33,8 +33,9 @@ export function useMyRelayMembershipQuery() { }); } -export function useMyRelayMembershipLookupQuery() { +export function useMyRelayMembershipLookupQuery(enabled = true) { return useQuery({ + enabled, queryKey: myRelayMembershipLookupQueryKey, queryFn: getMyRelayMembershipLookup, staleTime: 60_000, diff --git a/desktop/src/features/onboarding/communityOnboarding.test.mjs b/desktop/src/features/onboarding/communityOnboarding.test.mjs index e9645b191e9..b1878b2d304 100644 --- a/desktop/src/features/onboarding/communityOnboarding.test.mjs +++ b/desktop/src/features/onboarding/communityOnboarding.test.mjs @@ -78,9 +78,57 @@ test("same-relay ingress resumes rather than replacing progress", () => { new Date("2026-07-16T00:02:00Z"), ); assert.equal(resumed.id, progressed.id); - assert.equal(resumed.stage, "profile"); assert.equal(resumed.communityId, "community-id"); assert.equal(resumed.inviteCode, "new-code"); + // The new code is claimed before guest entry relies on it. + assert.equal(resumed.stage, "claiming"); +}); + +test("a same-relay invite keeps progress unless its code is new", () => { + const storage = createMemoryStorage(); + const first = startCommunityOnboarding( + { + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "first-code", + policyReceipt: "receipt-for-first-code", + }, + storage, + ); + updateCommunityOnboardingTransaction( + first, + { stage: "profile", error: "Not a member yet" }, + storage, + ); + + // Opening the same link again resumes where it was. + const reopened = startCommunityOnboarding( + { + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: " first-code ", + }, + storage, + ); + assert.equal(reopened.stage, "profile"); + assert.equal(reopened.policyReceipt, "receipt-for-first-code"); + assert.equal(reopened.error, undefined); + + // A different code, such as one redeemed on the membership-denied screen, + // is claimed, and never with the receipt minted for the old code. + const redeemed = startCommunityOnboarding( + { + source: "membership-recovery", + relayUrl: "wss://relay.example", + inviteCode: "second-code", + }, + storage, + ); + assert.equal(redeemed.id, first.id); + assert.equal(redeemed.stage, "claiming"); + assert.equal(redeemed.inviteCode, "second-code"); + assert.equal(redeemed.policyReceipt, undefined); + assert.equal(loadCommunityOnboardingTransaction(storage)?.stage, "claiming"); }); test("stale asynchronous updates cannot mutate a replacement transaction", () => { diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 99da68aa952..f502cf70d9f 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -154,15 +154,26 @@ export function startCommunityOnboarding( const relayUrl = canonicalRelayUrl(input.relayUrl); const existing = loadCommunityOnboardingTransaction(storage); if (existing?.relayUrl === relayUrl) { - const updated = { + const inviteCode = input.inviteCode?.trim(); + // A new invite code is claimed before anything relies on it, wherever + // the onboarding had got to (the membership-denied screen redeems one at + // the profile step). + const claimsNewInvite = Boolean( + inviteCode && inviteCode !== existing.inviteCode, + ); + const updated: CommunityOnboardingTransaction = { ...existing, + stage: claimsNewInvite ? "claiming" : existing.stage, firstCommunityPage: input.firstCommunityPage ?? existing.firstCommunityPage, - inviteCode: input.inviteCode?.trim() || existing.inviteCode, + inviteCode: inviteCode || existing.inviteCode, communityName: input.communityName?.trim() || existing.communityName, token: input.token?.trim() || existing.token, reposDir: input.reposDir ?? existing.reposDir, - policyReceipt: input.policyReceipt ?? existing.policyReceipt, + // A receipt is bound to the invite code it was minted for. + policyReceipt: claimsNewInvite + ? input.policyReceipt + : (input.policyReceipt ?? existing.policyReceipt), updatedAt: now.toISOString(), error: undefined, // A freshly opened link deserves fresh feedback — re-present the gate diff --git a/desktop/src/features/onboarding/guestEntry.ts b/desktop/src/features/onboarding/guestEntry.ts index 509649d355b..375367ce396 100644 --- a/desktop/src/features/onboarding/guestEntry.ts +++ b/desktop/src/features/onboarding/guestEntry.ts @@ -1,22 +1,34 @@ import type { CommunityOnboardingTransaction } from "@/features/onboarding/communityOnboarding"; import { guestLandingHash, + isGuestStarterChannel, isInviteJoin, joinsAsGuest, markCommunityGuest, + markGuestStarterChannelsJoined, pickGuestLandingChannel, + withJoinedStarterChannels, } from "@/features/onboarding/guestMode"; import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; -import { getChannels } from "@/shared/api/tauri"; +import { + getChannels, + getOpenChannelDirectory, + joinChannel, +} from "@/shared/api/tauri"; import type { Channel, RelayMemberRole } from "@/shared/api/types"; /** - * How long guest entry waits on the relay. Past it, the role counts as - * unknown (the invite decides) and the guest lands in Inbox. + * How long guest entry waits on each relay question. Past it, the role counts + * as unknown (the invite decides), the starter channels are joined once the + * app opens instead, and an unknown channel list lands the guest in Inbox. */ const GUEST_ENTRY_LOOKUP_TIMEOUT_MS = 8_000; -export type GuestEntry = { landingHash: string }; +export type GuestEntry = { + landingHash: string; + /** The owner's #general and #welcome-everyone were joined during entry. */ + startersJoined: boolean; +}; /** Resolves to `fallback` when `promise` rejects or takes longer than `timeoutMs`. */ async function settleWithin( @@ -59,54 +71,103 @@ async function loadJoinedChannels(): Promise { })); } +/** + * Join the community's existing open #general and #welcome-everyone, the way + * Block's phone app does after an invite. Accepting an invite adds a person + * to the community but to none of its channels. Creates nothing: a community + * whose owner removed these channels stays without them. Resolves to the + * starter channels, all joined, and rejects when any join fails. + */ +export async function joinGuestStarterChannels(): Promise { + const starters = (await getOpenChannelDirectory()).filter( + isGuestStarterChannel, + ); + await Promise.all( + starters + .filter((channel) => !channel.isMember) + .map((channel) => joinChannel(channel.id)), + ); + return starters.map((channel) => ({ ...channel, isMember: true })); +} + /** The relay questions guest entry asks; replaced in tests. */ export type GuestEntryLookups = { + joinStarterChannels: () => Promise; loadChannels: () => Promise; loadRole: () => Promise; timeoutMs: number; }; const RELAY_LOOKUPS: GuestEntryLookups = { + joinStarterChannels: joinGuestStarterChannels, loadChannels: loadJoinedChannels, loadRole: loadCommunityRole, timeoutMs: GUEST_ENTRY_LOOKUP_TIMEOUT_MS, }; +/** + * Join the starter channels, then read the channels the viewer belongs to. + * Each step has its own time budget, so a slow directory scan still leaves + * time to read the channels and land somewhere. + */ +async function loadGuestChannels(lookups: GuestEntryLookups) { + const starters = await settleWithin( + lookups.joinStarterChannels(), + null, + lookups.timeoutMs, + "Joining the community's starter channels", + ); + const channels = await settleWithin( + lookups.loadChannels(), + [], + lookups.timeoutMs, + "Loading your channels", + ); + return { + channels: withJoinedStarterChannels(channels, starters ?? []), + startersJoined: starters !== null, + }; +} + /** * Decide whether this onboarding enters the active community as a guest and, * if so, where the guest lands. Returns null for everyone else, who keep the * Welcome experience. Only invite joins ask the relay anything, and every * relay question is bounded: an unknown role leaves the invite to decide, * and an unknown channel list lands the guest in Inbox. + * + * The starter channels are joined while the role is read. Joining creates + * nothing, and an owner or admin who turns out not to be a guest joins them + * during their own setup anyway. */ export async function prepareGuestEntry( transaction: Pick | null, lookups: GuestEntryLookups = RELAY_LOOKUPS, ): Promise { if (!isInviteJoin(transaction)) return null; - const [role, channels] = await Promise.all([ + const [role, landing] = await Promise.all([ settleWithin( lookups.loadRole(), null, lookups.timeoutMs, "Reading your community role", ), - settleWithin( - lookups.loadChannels(), - [], - lookups.timeoutMs, - "Loading your channels", - ), + loadGuestChannels(lookups), ]); if (!joinsAsGuest(transaction, role)) return null; - return { landingHash: guestLandingHash(pickGuestLandingChannel(channels)) }; + return { + landingHash: guestLandingHash(pickGuestLandingChannel(landing.channels)), + startersJoined: landing.startersJoined, + }; } /** * Commit a prepared guest entry: remember guest mode for this identity and * community, then point the router at the landing place before the app * mounts. Callers mark onboarding complete afterwards, so a refused storage - * write throws here and leaves onboarding open to retry. + * write throws here and leaves onboarding open to retry. When the starter + * channels could not be joined yet, the app tries again each time it opens + * this community until it can. */ export function commitGuestEntry( entry: GuestEntry, @@ -114,5 +175,6 @@ export function commitGuestEntry( relayUrl: string, ): void { markCommunityGuest(pubkey, relayUrl); + if (entry.startersJoined) markGuestStarterChannelsJoined(pubkey, relayUrl); window.location.hash = entry.landingHash; } diff --git a/desktop/src/features/onboarding/guestMode.test.mjs b/desktop/src/features/onboarding/guestMode.test.mjs index bc96cf4d4f9..408b42adc7d 100644 --- a/desktop/src/features/onboarding/guestMode.test.mjs +++ b/desktop/src/features/onboarding/guestMode.test.mjs @@ -1,24 +1,53 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { prepareGuestEntry } from "./guestEntry.ts"; import { + commitGuestEntry, + joinGuestStarterChannels, + prepareGuestEntry, +} from "./guestEntry.ts"; +import { + clearCommunityGuest, guestLandingHash, + hasJoinedGuestStarterChannels, isCommunityGuest, + isGuestAgentHintDone, + isGuestStarterChannel, isInviteJoin, joinsAsGuest, markCommunityGuest, + markGuestAgentHintDone, + markGuestStarterChannelsJoined, pickGuestLandingChannel, + withJoinedStarterChannels, } from "./guestMode.ts"; function createMemoryStorage() { const values = new Map(); return { getItem: (key) => values.get(key) ?? null, + removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, String(value)), }; } +const REFUSING_STORAGE = { + getItem: () => { + throw new Error("storage unavailable"); + }, + removeItem: () => { + throw new Error("storage unavailable"); + }, + setItem: () => { + throw new Error("quota exceeded"); + }, +}; + +/** Silences the expected warnings of a test that exercises failures. */ +function quietWarnings(t) { + t.mock.method(console, "warn", () => {}); +} + function channel(overrides = {}) { return { archivedAt: null, @@ -110,24 +139,77 @@ test("guest mode is remembered per identity and community", () => { }); test("an unreadable store means not a guest, and a refused write throws", () => { - const broken = { - getItem: () => { - throw new Error("storage unavailable"); - }, - setItem: () => { - throw new Error("quota exceeded"); - }, - }; assert.equal( - isCommunityGuest("pubkey-a", "wss://hive.example.com", broken), + isCommunityGuest("pubkey-a", "wss://hive.example.com", REFUSING_STORAGE), false, ); assert.throws( - () => markCommunityGuest("pubkey-a", "wss://hive.example.com", broken), + () => + markCommunityGuest( + "pubkey-a", + "wss://hive.example.com", + REFUSING_STORAGE, + ), /quota exceeded/, ); }); +test("guest mode can be cleared for one identity and community", (t) => { + const storage = createMemoryStorage(); + markCommunityGuest("pubkey-a", "wss://hive.example.com", storage); + markCommunityGuest("pubkey-a", "wss://other.example.com", storage); + + clearCommunityGuest("PUBKEY-A", "wss://Hive.Example.com/", storage); + + assert.equal( + isCommunityGuest("pubkey-a", "wss://hive.example.com", storage), + false, + ); + assert.equal( + isCommunityGuest("pubkey-a", "wss://other.example.com", storage), + true, + ); + // A refused removal keeps the record for the next role check to clear. + quietWarnings(t); + assert.doesNotThrow(() => + clearCommunityGuest("pubkey-a", "wss://hive.example.com", REFUSING_STORAGE), + ); +}); + +test("the starter-join and agent-hint records are kept per identity and community", (t) => { + quietWarnings(t); + const records = [ + [hasJoinedGuestStarterChannels, markGuestStarterChannelsJoined], + [isGuestAgentHintDone, markGuestAgentHintDone], + ]; + for (const [isDone, markDone] of records) { + const storage = createMemoryStorage(); + assert.equal(isDone("pubkey-a", "wss://hive.example.com", storage), false); + markDone("PUBKEY-A", "wss://Hive.Example.com/", storage); + assert.equal(isDone("pubkey-a", "wss://hive.example.com", storage), true); + assert.equal(isDone("pubkey-b", "wss://hive.example.com", storage), false); + assert.equal(isDone("pubkey-a", "wss://other.example.com", storage), false); + assert.equal(isDone(null, "wss://hive.example.com", storage), false); + // Losing either record only repeats harmless work, so a refused write is + // not an error. + assert.doesNotThrow(() => + markDone("pubkey-a", "wss://hive.example.com", REFUSING_STORAGE), + ); + assert.equal( + isDone("pubkey-a", "wss://hive.example.com", REFUSING_STORAGE), + false, + ); + } + // Neither record marks someone as a guest. + const storage = createMemoryStorage(); + markGuestStarterChannelsJoined("pubkey-a", "wss://hive.example.com", storage); + markGuestAgentHintDone("pubkey-a", "wss://hive.example.com", storage); + assert.equal( + isCommunityGuest("pubkey-a", "wss://hive.example.com", storage), + false, + ); +}); + test("a guest lands in the most recently active channel the owner set up", () => { const channels = [ channel({ name: "general", lastMessageAt: "2026-10-12T10:00:00Z" }), @@ -185,18 +267,61 @@ test("with only the starter channels a guest lands in #welcome-everyone, else In ])?.name, "welcome-everyone", ); - // An open #welcome-everyone the guest has not joined yet still beats Inbox. + // Guest entry joins #welcome-everyone first; one the guest is not in is no + // landing place. assert.equal( pickGuestLandingChannel([ channel({ name: "general" }), channel({ name: "welcome-everyone", isMember: false }), - ])?.name, - "welcome-everyone", + ]), + null, ); assert.equal(pickGuestLandingChannel([channel({ name: "general" })]), null); assert.equal(pickGuestLandingChannel([]), null); }); +test("only the community's open, unarchived #general and #welcome-everyone are starters", () => { + const starters = [ + channel({ name: "general" }), + channel({ name: "Welcome-Everyone" }), + channel({ name: " general ", isMember: false }), + ]; + const others = [ + channel({ name: "general", visibility: "private" }), + channel({ name: "welcome-everyone", channelType: "forum" }), + channel({ name: "general", archivedAt: "2026-01-01T00:00:00Z" }), + channel({ name: "Welcome", visibility: "private" }), + channel({ name: "craig-ask-here" }), + ]; + for (const starter of starters) { + assert.equal(isGuestStarterChannel(starter), true, starter.name); + } + for (const other of others) { + assert.equal(isGuestStarterChannel(other), false, JSON.stringify(other)); + } +}); + +test("joined starter channels are added to a channel list that trails the join", () => { + const listed = channel({ id: "general-id", name: "general" }); + const merged = withJoinedStarterChannels( + [listed, channel({ id: "client-id", name: "client-room" })], + [ + channel({ id: "general-id", name: "general", isMember: false }), + channel({ id: "welcome-id", name: "welcome-everyone", isMember: false }), + ], + ); + assert.deepEqual( + merged.map((entry) => [entry.id, entry.isMember]), + [ + ["general-id", true], + ["client-id", true], + ["welcome-id", true], + ], + ); + // The listed copy wins, keeping its latest activity. + assert.equal(merged[0], listed); +}); + test("guestLandingHash opens the channel, or Inbox when there is none", () => { assert.equal( guestLandingHash(channel({ id: "a b/c" })), @@ -205,13 +330,26 @@ test("guestLandingHash opens the channel, or Inbox when there is none", () => { assert.equal(guestLandingHash(null), "/"); }); -function lookups({ role = null, channels = [], timeoutMs = 1_000 } = {}) { - const calls = { channels: 0, role: 0 }; +function lookups({ + role = null, + channels = [], + starters = [], + timeoutMs = 1_000, +} = {}) { + const calls = { channels: 0, role: 0, starters: 0 }; + const order = []; return { calls, + order, lookups: { + joinStarterChannels: async () => { + calls.starters += 1; + order.push("join starters"); + return typeof starters === "function" ? starters() : starters; + }, loadChannels: async () => { calls.channels += 1; + order.push("load channels"); return typeof channels === "function" ? channels() : channels; }, loadRole: async () => { @@ -227,7 +365,66 @@ test("prepareGuestEntry asks the relay nothing for people who did not use an inv const { calls, lookups: stub } = lookups({ role: "member" }); assert.equal(await prepareGuestEntry({}, stub), null); assert.equal(await prepareGuestEntry(null, stub), null); - assert.deepEqual(calls, { channels: 0, role: 0 }); + assert.deepEqual(calls, { channels: 0, role: 0, starters: 0 }); +}); + +test("a brand-new guest joins the starter channels and lands in #welcome-everyone", async () => { + // A new key belongs to no channel yet, and the relay's member list has not + // caught up with the joins. + const { order, lookups: stub } = lookups({ + role: "member", + channels: [], + starters: [ + channel({ id: "general-id", name: "general" }), + channel({ id: "welcome-id", name: "welcome-everyone" }), + ], + }); + assert.deepEqual(await prepareGuestEntry(INVITE, stub), { + landingHash: "/channels/welcome-id", + startersJoined: true, + }); + assert.deepEqual(order, ["join starters", "load channels"]); +}); + +test("the owner's channel for the guest still wins over the starter channels", async () => { + const entry = await prepareGuestEntry( + INVITE, + lookups({ + role: "member", + channels: [channel({ id: "client-id", name: "client-room" })], + starters: [channel({ id: "welcome-id", name: "welcome-everyone" })], + }).lookups, + ); + assert.deepEqual(entry, { + landingHash: "/channels/client-id", + startersJoined: true, + }); +}); + +test("a failed or slow starter join still lands the guest and is left to retry", async (t) => { + quietWarnings(t); + const failing = lookups({ + role: "member", + channels: [channel({ id: "client-id", name: "client-room" })], + starters: () => Promise.reject(new Error("join refused")), + }).lookups; + assert.deepEqual(await prepareGuestEntry(INVITE, failing), { + landingHash: "/channels/client-id", + startersJoined: false, + }); + + const hanging = lookups({ + role: "member", + channels: [channel({ id: "client-id", name: "client-room" })], + starters: () => new Promise(() => {}), + timeoutMs: 5, + }); + assert.deepEqual(await prepareGuestEntry(INVITE, hanging.lookups), { + landingHash: "/channels/client-id", + startersJoined: false, + }); + // The channel list still gets its own time after the join gives up. + assert.equal(hanging.calls.channels, 1); }); test("prepareGuestEntry keeps owners and admins out of guest mode", async () => { @@ -251,24 +448,151 @@ test("prepareGuestEntry lands an invited member in their channel", async () => { ], }).lookups, ); - assert.deepEqual(entry, { landingHash: "/channels/client-id" }); + assert.deepEqual(entry, { + landingHash: "/channels/client-id", + startersJoined: true, + }); }); -test("prepareGuestEntry falls back to the invite and Inbox when the relay fails", async () => { +test("prepareGuestEntry falls back to the invite and Inbox when the relay fails", async (t) => { + quietWarnings(t); + const unreachable = () => Promise.reject(new Error("relay unreachable")); const failing = lookups({ - role: () => Promise.reject(new Error("relay unreachable")), - channels: () => Promise.reject(new Error("relay unreachable")), + role: unreachable, + channels: unreachable, + starters: unreachable, }).lookups; assert.deepEqual(await prepareGuestEntry(INVITE, failing), { landingHash: "/", + startersJoined: false, }); + const never = () => new Promise(() => {}); const hanging = lookups({ - role: () => new Promise(() => {}), - channels: () => new Promise(() => {}), + role: never, + channels: never, + starters: never, timeoutMs: 5, }).lookups; assert.deepEqual(await prepareGuestEntry(INVITE, hanging), { landingHash: "/", + startersJoined: false, }); }); + +function rawChannel(overrides = {}) { + return { + archived_at: null, + channel_type: "stream", + description: "", + id: overrides.name ?? "chan", + is_member: false, + last_message_at: null, + member_count: 2, + member_pubkeys: [], + name: "chan", + participant_pubkeys: [], + participants: [], + purpose: null, + topic: null, + ttl_deadline: null, + ttl_seconds: null, + visibility: "open", + ...overrides, + }; +} + +function installTauriInvoke(t, handler) { + const prior = globalThis.window; + t.after(() => { + globalThis.window = prior; + }); + globalThis.window = { __TAURI_INTERNALS__: { invoke: handler } }; +} + +test("joinGuestStarterChannels joins only the starters the guest is not in, creating nothing", async (t) => { + const commands = []; + installTauriInvoke(t, async (command, args) => { + commands.push([command, args?.channelId ?? null]); + if (command === "get_open_channel_directory") { + return [ + rawChannel({ id: "general-id", name: "general" }), + rawChannel({ id: "welcome-id", name: "welcome-everyone" }), + rawChannel({ id: "random-id", name: "random" }), + rawChannel({ + id: "old-id", + name: "general", + archived_at: "2026-01-01T00:00:00Z", + }), + rawChannel({ + id: "member-id", + name: "welcome-everyone", + is_member: true, + }), + ]; + } + return null; + }); + + const starters = await joinGuestStarterChannels(); + + assert.deepEqual( + starters.map((entry) => [entry.id, entry.isMember]), + [ + ["general-id", true], + ["welcome-id", true], + ["member-id", true], + ], + ); + assert.deepEqual(commands, [ + ["get_open_channel_directory", null], + ["join_channel", "general-id"], + ["join_channel", "welcome-id"], + ]); +}); + +test("joinGuestStarterChannels fails when a join fails, so it is tried again", async (t) => { + installTauriInvoke(t, async (command) => { + if (command === "get_open_channel_directory") { + return [rawChannel({ id: "welcome-id", name: "welcome-everyone" })]; + } + throw new Error("relay rejected event"); + }); + + await assert.rejects(joinGuestStarterChannels(), /relay rejected event/); +}); + +test("commitGuestEntry records guest mode, a completed starter join and the landing", (t) => { + const prior = { + localStorage: globalThis.localStorage, + window: globalThis.window, + }; + t.after(() => { + globalThis.localStorage = prior.localStorage; + globalThis.window = prior.window; + }); + globalThis.localStorage = createMemoryStorage(); + globalThis.window = { location: { hash: "" } }; + + commitGuestEntry( + { landingHash: "/channels/welcome-id", startersJoined: false }, + "pubkey-a", + "wss://hive.example.com", + ); + assert.equal(isCommunityGuest("pubkey-a", "wss://hive.example.com"), true); + assert.equal( + hasJoinedGuestStarterChannels("pubkey-a", "wss://hive.example.com"), + false, + ); + assert.equal(globalThis.window.location.hash, "/channels/welcome-id"); + + commitGuestEntry( + { landingHash: "/", startersJoined: true }, + "pubkey-a", + "wss://hive.example.com", + ); + assert.equal( + hasJoinedGuestStarterChannels("pubkey-a", "wss://hive.example.com"), + true, + ); +}); diff --git a/desktop/src/features/onboarding/guestMode.ts b/desktop/src/features/onboarding/guestMode.ts index 95f97033731..7dae41acd35 100644 --- a/desktop/src/features/onboarding/guestMode.ts +++ b/desktop/src/features/onboarding/guestMode.ts @@ -18,16 +18,20 @@ import type { Channel, RelayMemberRole } from "@/shared/api/types"; */ const COMMUNITY_GUEST_STORAGE_KEY = "buzz-community-guest.v1"; +/** Set once a guest belongs to the owner's #general and #welcome-everyone. */ +const GUEST_STARTERS_JOINED_STORAGE_KEY = "buzz-guest-starters-joined.v1"; +/** Set once a guest has used or dismissed the hint about asking agents. */ +const GUEST_AGENT_HINT_DONE_STORAGE_KEY = "buzz-guest-agent-hint-done.v1"; -type GuestStorage = Pick; +type GuestStorage = Pick; type InviteTransaction = Pick; /** * Whether this community onboarding joins through an invite. Every invite * entry point (an opened invite link, an invite pasted into Join a - * community, Add a community, or the membership-denied screen) starts the - * transaction with the invite code, and onboarding only moves past - * "claiming" once the relay accepts that code. + * community, Add a community, or the membership-denied screen) puts the + * invite code on the transaction at the "claiming" stage, and onboarding only + * moves past "claiming" once the relay accepts that code. */ export function isInviteJoin( transaction: InviteTransaction | null | undefined, @@ -53,12 +57,50 @@ function normalizeCommunityScope(relayUrl: string) { return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); } -function communityGuestStorageKey(pubkey: string, relayUrl: string) { - return `${COMMUNITY_GUEST_STORAGE_KEY}:${encodeURIComponent( +/** A per-identity, per-community storage key. */ +function communityScopedKey(prefix: string, pubkey: string, relayUrl: string) { + return `${prefix}:${encodeURIComponent( normalizeCommunityScope(relayUrl), )}:${pubkey.trim().toLowerCase()}`; } +function defaultStorage(): GuestStorage | null { + return typeof localStorage === "undefined" ? null : localStorage; +} + +function readCommunityFlag( + prefix: string, + pubkey: string | null | undefined, + relayUrl: string | null | undefined, + storage: GuestStorage | null = defaultStorage(), +): boolean { + if (!pubkey || !relayUrl) return false; + try { + return ( + storage?.getItem(communityScopedKey(prefix, pubkey, relayUrl)) === "true" + ); + } catch { + return false; + } +} + +/** + * Best-effort write of a flag whose loss only repeats harmless work: a refused + * write leaves the flag unset, so the work runs again next time. + */ +function writeCommunityFlag( + prefix: string, + pubkey: string, + relayUrl: string, + storage: GuestStorage | null = defaultStorage(), +): void { + try { + storage?.setItem(communityScopedKey(prefix, pubkey, relayUrl), "true"); + } catch (error) { + console.warn("Could not save a guest setting for this community.", error); + } +} + /** * Remember that this identity is a guest of this community, so later launches * never create the private Welcome channel or the Welcome Team for it. Throws @@ -70,7 +112,10 @@ export function markCommunityGuest( relayUrl: string, storage: GuestStorage = localStorage, ): void { - storage.setItem(communityGuestStorageKey(pubkey, relayUrl), "true"); + storage.setItem( + communityScopedKey(COMMUNITY_GUEST_STORAGE_KEY, pubkey, relayUrl), + "true", + ); } export function isCommunityGuest( @@ -78,18 +123,88 @@ export function isCommunityGuest( relayUrl: string | null | undefined, storage?: GuestStorage, ): boolean { - if (!pubkey || !relayUrl) return false; + return readCommunityFlag( + COMMUNITY_GUEST_STORAGE_KEY, + pubkey, + relayUrl, + storage, + ); +} + +/** + * Forget guest mode for this identity and community: the relay lists them as + * its owner or admin, so the invite that classed them as a guest was only + * decided because the role lookup didn't answer. A refused removal keeps the + * record, and the next launch's role check clears it again. + */ +export function clearCommunityGuest( + pubkey: string, + relayUrl: string, + storage: GuestStorage | null = defaultStorage(), +): void { try { - const store = - storage ?? (typeof localStorage === "undefined" ? null : localStorage); - return ( - store?.getItem(communityGuestStorageKey(pubkey, relayUrl)) === "true" + storage?.removeItem( + communityScopedKey(COMMUNITY_GUEST_STORAGE_KEY, pubkey, relayUrl), ); - } catch { - return false; + } catch (error) { + console.warn("Could not clear guest mode for this community.", error); } } +/** Whether this guest already belongs to the owner's starter channels. */ +export function hasJoinedGuestStarterChannels( + pubkey: string | null | undefined, + relayUrl: string | null | undefined, + storage?: GuestStorage, +): boolean { + return readCommunityFlag( + GUEST_STARTERS_JOINED_STORAGE_KEY, + pubkey, + relayUrl, + storage, + ); +} + +export function markGuestStarterChannelsJoined( + pubkey: string, + relayUrl: string, + storage?: GuestStorage, +): void { + writeCommunityFlag( + GUEST_STARTERS_JOINED_STORAGE_KEY, + pubkey, + relayUrl, + storage, + ); +} + +/** Whether this guest has used or dismissed the hint about asking agents. */ +export function isGuestAgentHintDone( + pubkey: string | null | undefined, + relayUrl: string | null | undefined, + storage?: GuestStorage, +): boolean { + return readCommunityFlag( + GUEST_AGENT_HINT_DONE_STORAGE_KEY, + pubkey, + relayUrl, + storage, + ); +} + +export function markGuestAgentHintDone( + pubkey: string, + relayUrl: string, + storage?: GuestStorage, +): void { + writeCommunityFlag( + GUEST_AGENT_HINT_DONE_STORAGE_KEY, + pubkey, + relayUrl, + storage, + ); +} + const STARTER_CHANNEL_NAMES = new Set([ STARTER_GENERAL_CHANNEL_NAME, STARTER_WELCOME_CHANNEL_NAME, @@ -99,6 +214,38 @@ function isStarterChannelName(channel: Channel) { return STARTER_CHANNEL_NAMES.has(channel.name.trim().toLowerCase()); } +/** + * The community's own #general or #welcome-everyone, the open channels every + * member is meant to be in. Accepting an invite joins the community, not its + * channels, so a guest joins these, and only these, once they are in. + */ +export function isGuestStarterChannel(channel: Channel): boolean { + return ( + channel.channelType === "stream" && + channel.visibility === "open" && + channel.archivedAt === null && + isStarterChannelName(channel) + ); +} + +/** + * The guest's channel list with the starter channels they just joined added. + * The relay's member list can trail a join that was just sent, so a joined + * starter may be missing from `channels` for a moment. + */ +export function withJoinedStarterChannels( + channels: readonly Channel[], + joinedStarters: readonly Channel[], +): Channel[] { + const listed = new Set(channels.map((channel) => channel.id)); + return [ + ...channels, + ...joinedStarters + .filter((channel) => !listed.has(channel.id)) + .map((channel) => ({ ...channel, isMember: true })), + ]; +} + function lastActivity(channel: Channel) { const time = channel.lastMessageAt ? Date.parse(channel.lastMessageAt) : NaN; return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time; @@ -133,7 +280,6 @@ export function pickGuestLandingChannel( usable.find( (channel) => isStarterWelcomeChannel(channel) && channel.isMember, ) ?? - usable.find(isStarterWelcomeChannel) ?? null ); } diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index 3e7ab99e350..d0070e6aa90 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -16,7 +16,14 @@ import { rememberPendingWelcomeChannel, } from "@/features/onboarding/welcome"; import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding"; -import { isCommunityGuest } from "@/features/onboarding/guestMode"; +import { joinGuestStarterChannels } from "@/features/onboarding/guestEntry"; +import { + hasJoinedGuestStarterChannels, + isCommunityGuest, + markGuestStarterChannelsJoined, + withJoinedStarterChannels, +} from "@/features/onboarding/guestMode"; +import { useCommunityGuestStatus } from "@/features/onboarding/useIsCommunityGuest"; import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas"; import { ensureWelcomeTeam } from "@/features/onboarding/welcomeGuide"; import { useProfileQuery } from "@/features/profile/hooks"; @@ -72,6 +79,31 @@ function seedWelcomeExperience( return promise; } +/** + * Join the owner's #general and #welcome-everyone for a guest whose entry + * could not. The missing "joined" record is the retry: a failure here is + * tried again the next time the app opens this community. + */ +async function joinGuestStarterChannelsOnce( + queryClient: ReturnType, + pubkey: string, + communityScope: string, +) { + if (hasJoinedGuestStarterChannels(pubkey, communityScope)) return; + try { + const starters = await joinGuestStarterChannels(); + markGuestStarterChannelsJoined(pubkey, communityScope); + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + // The relay's member list can trail the join; keep the joined channels + // in the sidebar until it catches up. + queryClient.setQueryData(channelsQueryKey, (channels) => + channels ? withJoinedStarterChannels(channels, starters) : channels, + ); + } catch (error) { + console.warn("Failed to join the community's starter channels.", error); + } +} + export async function initializeStarterChannels( queryClient: ReturnType, { @@ -84,9 +116,11 @@ export async function initializeStarterChannels( communityScope: string | null; }, ): Promise { - if (isCommunityGuest(pubkey, communityScope)) { + if (pubkey && communityScope && isCommunityGuest(pubkey, communityScope)) { // Guests use the community its owner set up: no private Welcome channel, - // no Welcome Team, and no starter channels created in their name. + // no Welcome Team, and no starter channels created in their name. They + // only join the owner's own, here when guest entry could not. + await joinGuestStarterChannelsOnce(queryClient, pubkey, communityScope); return { ok: true }; } try { @@ -577,11 +611,19 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { [currentPubkey, queryClient, starterChannelsCommunityScope], ); + // A saved guest flag waits for the relay's word on this person's role: an + // owner or admin wrongly saved as a guest is cleared first and gets the + // normal setup here. + const guestStatus = useCommunityGuestStatus( + currentPubkey, + starterChannelsCommunityScope, + ); React.useEffect(() => { if ( onboardingGate.stage !== "ready" || !currentPubkey || !starterChannelsCommunityScope || + !guestStatus.isSettled || !readOnboardingCompletion(currentPubkey) || hasEnsuredWelcomeChannel(currentPubkey, starterChannelsCommunityScope) ) { @@ -591,6 +633,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { void requestStarterChannels(false); }, [ currentPubkey, + guestStatus.isSettled, onboardingGate.stage, requestStarterChannels, starterChannelsCommunityScope, diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 2bd0416e497..256623a177c 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -231,6 +231,16 @@ export function CommunityOnboardingFlow({ useClaimInvite(); + // An invite redeemed on the membership-denied screen sends onboarding back + // to claiming; close that screen so the claim and what follows show. + const inviteCode = transaction?.inviteCode; + const shownInviteCodeRef = React.useRef(inviteCode); + React.useEffect(() => { + if (shownInviteCodeRef.current === inviteCode) return; + shownInviteCodeRef.current = inviteCode; + setIsMembershipDenied(false); + }, [inviteCode]); + React.useEffect(() => { if (transaction?.stage === "connecting") onConnect(); }, [onConnect, transaction?.stage]); @@ -356,11 +366,17 @@ export function CommunityOnboardingFlow({ checkedProfileTransactionRef.current = transaction.id; void getProfile() - .then((profile) => - profile.hasProfileEvent - ? continueAfterProfile(transaction.id) - : undefined, - ) + .then(async (profile) => { + if (!profile.hasProfileEvent) return; + // Hold the form while guest entry asks the relay, so nobody starts + // typing into a screen that is about to close or presses Next twice. + setIsPending(true); + try { + await continueAfterProfile(transaction.id); + } finally { + setIsPending(false); + } + }) .catch(() => { // Discovery is best-effort. Staying on the profile step preserves the // existing path when the relay cannot answer the lookup. @@ -602,8 +618,8 @@ export function CommunityOnboardingFlow({ Build your profile

- Add a name and avatar. They’ll show up on your messages, - reactions, and agent handoffs. + Add a name and avatar. They’ll show up on your messages + and reactions.

@@ -618,10 +634,10 @@ export function CommunityOnboardingFlow({ htmlFor="community-display-name" > - Your username + Your name setDisplayName(event.target.value)} - placeholder="Enter your username here" + placeholder="Enter your name here" ref={nameInputRef} spellCheck={false} type="text" @@ -659,7 +675,11 @@ export function CommunityOnboardingFlow({ onClick={() => void saveProfile()} type="button" > - Next + {isPending ? ( + + ) : ( + "Next" + )} { + if (!skipProviderSetup || page !== "setup") return; + if (skippedProviderSetupRef.current) return; + skippedProviderSetupRef.current = true; + complete(selectedPubkey ?? undefined, { + continueToProfile: !identityWasImported, + }); + }, [complete, identityWasImported, page, selectedPubkey, skipProviderSetup]); const returnToApiConfig = React.useCallback(() => { setIsChoosingDifferentHarness(false); setTransitionDirection("backward"); diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index 4c60a8a4b41..f2f06826265 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -113,8 +113,8 @@ export function MembershipDenied({

- This relay requires an invitation. Ask a relay admin to add you as a - member, then come back and try again. + This community server requires an invitation. Ask a community admin + to add you as a member, then come back and try again.

@@ -147,8 +147,8 @@ export function MembershipDenied({

- This is your public identity — it's safe to share. Send it to - the relay admin so they can invite you. + This is your public identity — it's safe to share. Send it to a + community admin so they can invite you.

diff --git a/desktop/src/features/onboarding/useIsCommunityGuest.ts b/desktop/src/features/onboarding/useIsCommunityGuest.ts index 4fe3a364e78..b8f47290e0d 100644 --- a/desktop/src/features/onboarding/useIsCommunityGuest.ts +++ b/desktop/src/features/onboarding/useIsCommunityGuest.ts @@ -1,19 +1,84 @@ import * as React from "react"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; -import { isCommunityGuest } from "@/features/onboarding/guestMode"; +import { + clearCommunityGuest, + isCommunityGuest, + isGuestAgentHintDone, + markGuestAgentHintDone, +} from "@/features/onboarding/guestMode"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { canManageCommunityMembers } from "@/shared/api/relayMembers"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type CommunityGuestStatus = { + /** This identity is a guest of this community. */ + isGuest: boolean; + /** + * The answer is final for now: nobody saved as a guest, or the relay has + * answered the role check for someone who was. + */ + isSettled: boolean; +}; /** - * Whether the current identity joined the active community as a guest, by - * invite. Guest mode is recorded before the app mounts for that community, so - * reading it once per identity and community is enough. + * Whether `pubkey` joined the community at `relayUrl` as a guest, by invite. + * The saved guest flag corrects itself: guest entry decides on the invite + * alone when the role lookup doesn't answer, so when the relay later lists + * this person as the community's owner or admin, the flag is cleared and they + * get the normal experience back. Only people saved as guests ask the relay. */ +export function useCommunityGuestStatus( + pubkey: string | null, + relayUrl: string | null, +): CommunityGuestStatus { + const isSavedGuest = React.useMemo( + () => isCommunityGuest(pubkey, relayUrl), + [pubkey, relayUrl], + ); + const membershipQuery = useMyRelayMembershipLookupQuery(isSavedGuest); + const lookup = isSavedGuest ? membershipQuery.data : undefined; + const isOwnerOrAdmin = + pubkey !== null && + lookup?.membership != null && + normalizePubkey(lookup.membership.pubkey) === normalizePubkey(pubkey) && + canManageCommunityMembers(lookup); + + React.useEffect(() => { + if (isOwnerOrAdmin && pubkey && relayUrl) { + clearCommunityGuest(pubkey, relayUrl); + } + }, [isOwnerOrAdmin, pubkey, relayUrl]); + + return { + isGuest: isSavedGuest && !isOwnerOrAdmin, + isSettled: !isSavedGuest || membershipQuery.status !== "pending", + }; +} + +/** Whether the current identity joined the active community as a guest. */ export function useIsCommunityGuest(): boolean { + return useCommunityGuest().isGuest; +} + +/** + * Guest mode for the current identity in the active community, with the + * guest's hint about asking agents: whether it was used or dismissed on an + * earlier visit, and a way to record that it has been now. Recording does not + * change `agentHintDone` until the next visit, so the hint can finish its own + * "Nice work." exit. + */ +export function useCommunityGuest() { const pubkey = useIdentityQuery().data?.pubkey ?? null; const relayUrl = useCommunities().activeCommunity?.relayUrl ?? null; - return React.useMemo( - () => isCommunityGuest(pubkey, relayUrl), + const { isGuest } = useCommunityGuestStatus(pubkey, relayUrl); + const agentHintDone = React.useMemo( + () => isGuestAgentHintDone(pubkey, relayUrl), [pubkey, relayUrl], ); + const markAgentHintDone = React.useCallback(() => { + if (pubkey && relayUrl) markGuestAgentHintDone(pubkey, relayUrl); + }, [pubkey, relayUrl]); + return { agentHintDone, isGuest, markAgentHintDone }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d9c9fc2833a..94ed20a32b0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3392,6 +3392,8 @@ let mockSaveSubscriptions: MockSaveSubscriptionRow[] = []; // command uses — so history/save-conflict/restore journeys run against real // state instead of a fixed stub. type MockCanvasRevisionSeed = { + /** Optional; defaults to the starter #general channel. */ + channelId?: string; content: string; /** Optional; defaults to a monotonically increasing second per seed. */ createdAt?: number; @@ -3418,14 +3420,18 @@ function resetMockCanvasRevisions(config: E2eConfig | undefined) { return; } // Seeds are oldest-first; store newest-first so index 0 is always the head. - const revisions = seeds.map((seed, index) => ({ - eventId: seed.eventId ?? mockEventId(), - content: seed.content, - createdAt: seed.createdAt ?? 1_700_000_000 + index, - author: seed.author ?? DEFAULT_MOCK_IDENTITY.pubkey, - })); - revisions.reverse(); - mockCanvasRevisions.set(DEFAULT_STARTER_CANVAS_CHANNEL, revisions); + seeds.forEach((seed, index) => { + const channelId = seed.channelId ?? DEFAULT_STARTER_CANVAS_CHANNEL; + mockCanvasRevisions.set(channelId, [ + { + eventId: seed.eventId ?? mockEventId(), + content: seed.content, + createdAt: seed.createdAt ?? 1_700_000_000 + index, + author: seed.author ?? DEFAULT_MOCK_IDENTITY.pubkey, + }, + ...(mockCanvasRevisions.get(channelId) ?? []), + ]); + }); } type MockObservedUnreadScope = { @@ -13572,6 +13578,23 @@ export function maybeInstallE2eTauriMocks() { activeWorkspaceId: null, onboardingCompletions: [], }; + // Test-only: a community link that arrives while the app is running. + // Specs queue it here, then emit the matching deep-link event. + case "e2e_queue_pending_community_deep_link": { + const pending = payload as { + id: string; + kind: string; + relayUrl: string; + code?: string | null; + name?: string | null; + }; + mockPendingCommunityDeepLinks.push({ + ...pending, + code: pending.code ?? null, + name: pending.name ?? null, + }); + return null; + } case "take_pending_community_deep_link": // Mirrors the Rust queue: peek the head; acknowledge removes it. return mockPendingCommunityDeepLinks[0] ?? null; diff --git a/desktop/tests/e2e/channel-about.spec.ts b/desktop/tests/e2e/channel-about.spec.ts index f7be2182eab..07ae3951ad4 100644 --- a/desktop/tests/e2e/channel-about.spec.ts +++ b/desktop/tests/e2e/channel-about.spec.ts @@ -6,6 +6,8 @@ import { installMockBridge } from "../helpers/bridge"; const CANVAS = "# Start here\n\nAsk the Reset Worker when you need a hand."; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const GENERAL_THREAD_ROOT_ID = "mock-general-welcome"; +// The mock's first project uses the "buzz" channel as its home. +const PROJECT_HOME_CHANNEL_ID = "cf63feec-21bb-5bf0-a2f8-0e4c3de8ec73"; async function openChannel(page: Page, name: string) { await page.getByTestId(`channel-${name}`).click(); @@ -116,6 +118,36 @@ test("a narrow window keeps the messages and leaves About to the button", async await expect(about).toContainText("Start here"); }); +test("a project's home channel leaves its command list to the About button", async ({ + page, +}) => { + // Wide enough for About to fit beside the project overview and messages. + await page.setViewportSize({ height: 1080, width: 1920 }); + await installMockBridge(page, { + canvasRevisions: [ + { + channelId: PROJECT_HOME_CHANNEL_ID, + content: "# Project Channel: buzz\n\n| Action | Command |", + }, + ], + }); + await page.goto("/"); + await openChannel(page, "buzz"); + + const about = page.getByTestId("channel-about-panel"); + const trigger = page.getByTestId("channel-about-trigger"); + // Known as a project home, and the canvas has loaded. + await expect( + page.getByTestId("chat-header").getByTestId("project-channel-icon"), + ).toBeVisible(); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-pressed", "false"); + await expect(about).toHaveCount(0); + + await trigger.click(); + await expect(about).toContainText("Project Channel"); +}); + test("channels without a canvas show no About button and open nothing", async ({ page, }) => { diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index fab25a1fbb9..3897a3143ae 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -421,14 +421,25 @@ test("persisted deep-link invite hands off to Joining after machine onboarding", // Guest mode: someone who joins a community through an invite skips // AI-provider setup, gets no private Welcome channel, Welcome Team or -// kickoff, and lands in a channel the owner set up for them. +// kickoff, joins the owner's #general and #welcome-everyone, and lands in a +// channel the owner set up for them. const GUEST_STORAGE_KEY = `buzz-community-guest.v1:${encodeURIComponent( COMMUNITY_RELAY_URL, )}:${DEFAULT_MOCK_PUBKEY}`; +const GUEST_HINT_DONE_KEY = `buzz-guest-agent-hint-done.v1:${encodeURIComponent( + COMMUNITY_RELAY_URL, +)}:${DEFAULT_MOCK_PUBKEY}`; // The fixture's most recently active channel this member belongs to, other // than #general and #welcome-everyone. const GUEST_LANDING_CHANNEL_ID = "fa11bac0-0000-4000-8000-000000000012"; +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const WELCOME_EVERYONE_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; +// A key that belongs to no channel yet, like Craig's on a new Mac. +const NEW_GUEST = TEST_IDENTITIES.outsider; +const RESET_WORKER_PUBKEY = "ab".repeat(32); +const GUEST_HINT = "Type @ and an agent's name to ask it for something."; async function commandCount(page: Page, command: string) { return page.evaluate( @@ -439,9 +450,18 @@ async function commandCount(page: Page, command: string) { ); } +async function joinedChannelIds(page: Page) { + return page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => entry.command === "join_channel") + .map((entry) => (entry.payload as { channelId: string }).channelId), + ); +} + async function seedInviteTransaction( page: Page, stage: "claiming" | "profile", + pubkey: string = DEFAULT_MOCK_PUBKEY, ) { await page.addInitScript( ({ pubkey, relayUrl, stage, storageKey }) => { @@ -468,7 +488,7 @@ async function seedInviteTransaction( ); }, { - pubkey: DEFAULT_MOCK_PUBKEY, + pubkey, relayUrl: COMMUNITY_RELAY_URL, stage, storageKey: TRANSACTION_STORAGE_KEY, @@ -476,12 +496,13 @@ async function seedInviteTransaction( ); } -async function expectGuestLanding(page: Page) { +async function expectGuestLanding( + page: Page, + landing = { channelId: GUEST_LANDING_CHANNEL_ID, name: "all-replies" }, +) { await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0); - await expect(page).toHaveURL( - new RegExp(`#/channels/${GUEST_LANDING_CHANNEL_ID}$`), - ); - await expect(page.getByTestId("chat-title")).toHaveText("all-replies"); + await expect(page).toHaveURL(new RegExp(`#/channels/${landing.channelId}$`)); + await expect(page.getByTestId("chat-title")).toHaveText(landing.name); await expect(page.getByTestId("channel-Welcome")).toHaveCount(0); // Guest mode is remembered for this community, whatever the identity. expect( @@ -496,7 +517,8 @@ async function expectGuestLanding(page: Page) { ), ).toBe(1); // Nothing is created in the guest's name: no private Welcome channel, no - // starter channels, no Welcome Team, no kickoff. + // starter channels, no Welcome Team, no kickoff. Joining the owner's + // channels is fine. for (const command of [ "create_channel", "ensure_starter_channels", @@ -574,6 +596,140 @@ test("an invited guest skips the starter team and lands in their channel", async ).toHaveCount(0); }); +test("a guest with a new key joins #general and #welcome-everyone and lands there", async ({ + page, +}) => { + // Accepting an invite joins the community but none of its channels, and + // nobody could add this key to a channel before it existed. + await seedActiveIdentity(page, NEW_GUEST); + await seedInviteTransaction(page, "profile", NEW_GUEST.pubkey); + await installMockBridge( + page, + { profileHasEvent: false }, + { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Craig"); + await page.getByTestId("community-profile-next").click(); + + await expectGuestLanding(page, { + channelId: WELCOME_EVERYONE_CHANNEL_ID, + name: "welcome-everyone", + }); + await expect(page.getByTestId("channel-general")).toBeVisible(); + await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible(); + // Joined once each, at entry; opening the app does not join them again. + await expect(page.getByTestId("message-input")).toBeVisible(); + expect((await joinedChannelIds(page)).sort()).toEqual( + [GENERAL_CHANNEL_ID, WELCOME_EVERYONE_CHANNEL_ID].sort(), + ); +}); + +test("starter channels a guest could not join at entry are joined when the app opens", async ({ + page, +}) => { + await seedActiveIdentity(page, NEW_GUEST); + await seedInviteTransaction(page, "profile", NEW_GUEST.pubkey); + await installMockBridge( + page, + { joinChannelErrors: ["relay unavailable"], profileHasEvent: false }, + { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Craig"); + await page.getByTestId("community-profile-next").click(); + + // #general's join failed at entry; #welcome-everyone's went through. + await expectGuestLanding(page, { + channelId: WELCOME_EVERYONE_CHANNEL_ID, + name: "welcome-everyone", + }); + await expect(page.getByTestId("channel-general")).toBeVisible(); + await expect + .poll(() => joinedChannelIds(page)) + .toEqual([ + GENERAL_CHANNEL_ID, + WELCOME_EVERYONE_CHANNEL_ID, + GENERAL_CHANNEL_ID, + ]); +}); + +test("Next on the profile step says the community is opening while guest entry waits", async ({ + page, +}) => { + await seedInviteTransaction(page, "profile"); + await installMockBridge( + page, + { channelsReadDelayMs: 1_500, profileHasEvent: false }, + { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Craig"); + await page.getByTestId("community-profile-next").click(); + + const next = page.getByTestId("community-profile-next"); + await expect(next).toHaveAccessibleName("Opening your community"); + await expect(next).toBeDisabled(); + await expectGuestLanding(page); +}); + +test("an invited guest whose profile already exists waits on a locked form", async ({ + page, +}) => { + // The default mock identity already has a kind:0 profile, so the profile + // step enters by itself. + await seedInviteTransaction(page, "profile"); + await installMockBridge( + page, + { channelsReadDelayMs: 1_500 }, + { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await expect(page.getByTestId("community-profile-name-key")).toBeDisabled(); + await expect(page.getByTestId("community-profile-next")).toHaveAccessibleName( + "Opening your community", + ); + await expectGuestLanding(page); +}); + +test("an invite link opened on the AI-provider step skips the rest of it", async ({ + page, +}) => { + await fulfillInviteClaim(page); + await installMockBridge( + page, + { profileHasEvent: false }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByRole("button", { name: "Create a new identity key" }).click(); + await page.getByRole("button", { name: "Create my private key" }).click(); + await page.getByTestId("onboarding-next").click(); + await expect( + page.getByRole("heading", { name: "Connect your AI provider" }), + ).toBeVisible(); + + await page.evaluate(async (link) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "e2e_queue_pending_community_deep_link", + link, + ); + await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("deep-link-join", null); + }, PENDING_JOIN_LINK); + + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Connect your AI provider" }), + ).toHaveCount(0); +}); + test("an invited guest with an existing profile still lands as a guest", async ({ page, }) => { @@ -620,6 +776,11 @@ test("a community admin who uses an invite keeps the starter team", async ({ ).toBeNull(); }); +async function openChannelAsGuest(page: Page, name: string) { + await page.getByTestId(`channel-${name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(name); +} + async function openWelcomeEveryoneAsGuest( page: Page, mock: Parameters[1], @@ -629,33 +790,192 @@ async function openWelcomeEveryoneAsGuest( }, GUEST_STORAGE_KEY); await installMockBridge(page, mock, { relayWsUrl: COMMUNITY_RELAY_URL }); await page.goto("/"); - await page.getByTestId("channel-welcome-everyone").click(); - await expect(page.getByTestId("chat-title")).toHaveText("welcome-everyone"); + await openChannelAsGuest(page, "welcome-everyone"); +} + +function guestHint(page: Page) { + return page + .getByTestId("welcome-composer-guide-banner") + .getByTestId("welcome-composer-guest-hint"); +} + +function resetWorker( + agent: Partial<{ + channelNames: string[]; + ownerPubkey: string; + respondTo: "anyone" | "owner-only"; + }> = {}, +) { + return { + pubkey: RESET_WORKER_PUBKEY, + name: "Reset Worker", + respondTo: "anyone" as const, + channelNames: ["welcome-everyone"], + ...agent, + }; } test("in #welcome-everyone a guest is told how to ask the community's agents", async ({ page, }) => { - await openWelcomeEveryoneAsGuest(page, { - relayAgents: [ - { - pubkey: "ab".repeat(32), - name: "Reset Worker", - respondTo: "anyone", - }, - ], - }); + await openWelcomeEveryoneAsGuest(page, { relayAgents: [resetWorker()] }); const banner = page.getByTestId("welcome-composer-guide-banner"); - await expect(banner.getByTestId("welcome-composer-guest-hint")).toHaveText( - "Type @ and an agent's name to ask it for something.", - ); + await expect(guestHint(page)).toHaveText(GUEST_HINT); await expect(banner).not.toContainText("Fizz"); await expect( page.getByTestId("welcome-composer-persona-mention"), ).toHaveCount(0); + // The @ list offers the agent the hint is about. + await page.getByTestId("message-input").fill("@Reset"); + await expect( + page + .getByTestId("message-composer") + .getByTestId(`mention-suggestion-${RESET_WORKER_PUBKEY}`), + ).toBeVisible(); }); +for (const { agent, title } of [ + { + title: "answers anyone but is not in the channel", + agent: resetWorker({ channelNames: ["random"] }), + }, + { + title: "answers only its owner", + agent: resetWorker({ + ownerPubkey: TEST_IDENTITIES.charlie.pubkey, + respondTo: "owner-only", + }), + }, +]) { + test(`a guest gets no hint where the only agent ${title}`, async ({ + page, + }) => { + await openWelcomeEveryoneAsGuest(page, { relayAgents: [agent] }); + + // #general's agents answer anyone, so the hint shows there once the + // agent lists have loaded... + await openChannelAsGuest(page, "general"); + await expect(guestHint(page)).toHaveText(GUEST_HINT); + // ...and still not in #welcome-everyone, where the @ list has none. + await openChannelAsGuest(page, "welcome-everyone"); + await expect(page.getByTestId("message-input")).toBeVisible(); + await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( + 0, + ); + }); +} + +test("a guest is told how to ask agents in their own channel until they dismiss it", async ({ + page, +}) => { + await openWelcomeEveryoneAsGuest(page, { + relayAgents: [resetWorker({ channelNames: ["random"] })], + }); + await openChannelAsGuest(page, "random"); + await expect(guestHint(page)).toHaveText(GUEST_HINT); + + await page.getByTestId("welcome-composer-dismiss-button").click(); + await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( + 0, + ); + expect( + await page.evaluate( + (key) => localStorage.getItem(key), + GUEST_HINT_DONE_KEY, + ), + ).toBe("true"); + + // Gone for good in this community: other channels, and after a restart. + await openChannelAsGuest(page, "general"); + await expect(page.getByTestId("message-input")).toBeVisible(); + await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( + 0, + ); + await page.reload(); + await openChannelAsGuest(page, "random"); + await expect(page.getByTestId("message-input")).toBeVisible(); + await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( + 0, + ); +}); + +test("asking an agent finishes the guest hint for good", async ({ page }) => { + await openWelcomeEveryoneAsGuest(page, { + relayAgents: [resetWorker({ channelNames: ["random"] })], + }); + // The owner added the agent to the guest's channel. + await page.evaluate( + async ({ channelId, pubkey }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + }, + { channelId: RANDOM_CHANNEL_ID, pubkey: RESET_WORKER_PUBKEY }, + ); + await openChannelAsGuest(page, "random"); + await expect(guestHint(page)).toHaveText(GUEST_HINT); + + await page.getByTestId("message-input").fill("@Reset"); + await page + .getByTestId("message-composer") + .getByTestId(`mention-suggestion-${RESET_WORKER_PUBKEY}`) + .click(); + await page.getByTestId("message-input").press("End"); + await page.getByTestId("message-input").pressSequentially(" plan my week"); + await page.getByTestId("message-input").press("Enter"); + + await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText( + "Nice work.", + ); + await expect + .poll(() => + page.evaluate((key) => localStorage.getItem(key), GUEST_HINT_DONE_KEY), + ) + .toBe("true"); + await page.reload(); + await openChannelAsGuest(page, "random"); + await expect(page.getByTestId("message-input")).toBeVisible(); + await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( + 0, + ); +}); + +for (const { relayRole, staysGuest } of [ + { relayRole: "owner" as const, staysGuest: false }, + { relayRole: "admin" as const, staysGuest: false }, + { relayRole: "member" as const, staysGuest: true }, +]) { + test(`a saved guest whom the relay lists as ${relayRole} ${ + staysGuest ? "stays a guest" : "gets the normal experience back" + }`, async ({ page }) => { + // Guest entry decides on the invite alone when the role lookup doesn't + // answer; the relay's later answer corrects an owner or admin. + await openWelcomeEveryoneAsGuest(page, { + relayAgents: [resetWorker()], + relayRequiresMembership: true, + relayRole, + }); + + if (staysGuest) { + await expect(guestHint(page)).toHaveText(GUEST_HINT); + } else { + await expect( + page.getByTestId("welcome-composer-persona-mention"), + ).toBeVisible(); + await expect(guestHint(page)).toHaveCount(0); + } + expect( + await page.evaluate( + (key) => localStorage.getItem(key), + GUEST_STORAGE_KEY, + ), + ).toBe(staysGuest ? "true" : null); + }); +} + test("in #welcome-everyone a guest who can see no agents gets no hint", async ({ page, }) => { diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 9ea81f450f2..4eb49c9efc3 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2408,7 +2408,7 @@ test("connected first-community profile keeps navigation inside the card and bal borderRadius: "12px", fontSize: "14px", }); - await expect(page.getByText("Your username", { exact: true })).toBeVisible(); + await expect(page.getByText("Your name", { exact: true })).toBeVisible(); await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute( "data-system-color-scheme", /^(light|dark)$/, @@ -3251,6 +3251,84 @@ test("membership denial on community profile save offers recovery", async ({ .toContain("wss://invited.example.com"); }); +test("an invite redeemed on the membership-denied screen is claimed", async ({ + page, +}) => { + const claimedCodes: string[] = []; + await page.route("**/api/join-policy", (route) => + route.fulfill({ status: 404, body: "" }), + ); + await page.route("**/api/invites/claim", async (route) => { + claimedCodes.push( + (route.request().postDataJSON() as { code: string }).code, + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "joined", + community_id: "mock-community", + host: "denied.example.com", + role: "member", + }), + }); + }); + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript( + ({ pubkey, transactionStorageKey }) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + transactionStorageKey, + JSON.stringify({ + id: "txn-membership-denied-invite", + source: "first-community", + stage: "profile", + relayUrl: "wss://denied.example.com", + communityName: "Denied", + communityId: "e2e-default-community", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + }, + { + pubkey: BLANK_TYLER_IDENTITY.pubkey, + transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, + }, + ); + await installMockBridge( + page, + { + profileUpdateError: + "relay returned 403 Forbidden: You must be a relay member to access this relay", + }, + { + relayWsUrl: "wss://denied.example.com", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Kalvin"); + await page.getByTestId("community-profile-next").click(); + await expect(page.getByTestId("membership-denied")).toBeVisible(); + + await page.getByTestId("membership-denied-redeem-invite").click(); + await page.getByTestId("invite-redeem-input").fill("fresh.code"); + await page.getByTestId("invite-redeem-submit").click(); + + // The denied screen closes, the new code is claimed, and onboarding goes on. + await expect(page.getByTestId("membership-denied")).toHaveCount(0); + await expect.poll(() => claimedCodes).toEqual(["fresh.code"]); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); +}); + test("identity fallback text does not count as a real onboarding name", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c074cc419f8..04efedc6613 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -295,6 +295,8 @@ type MockBridgeOptions = { canvasReadError?: string; /** Seed canvas revisions (oldest first); see e2eBridge mock config. */ canvasRevisions?: Array<{ + /** Defaults to the starter #general channel. */ + channelId?: string; content: string; createdAt?: number; eventId?: string; From 47ca831ed7229d01d3379d8f4c39c0c1dc4a38aa Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 15:27:30 -0500 Subject: [PATCH 23/28] test(desktop): cover a slow relay correcting an owner saved as a guest The open #welcome-everyone must drop guest mode when the relay's late role answer lists the person as owner, without a reload. Co-Authored-By: Claude Opus 5.5 --- desktop/tests/e2e/deep-link-invite.spec.ts | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index 3897a3143ae..986def940cc 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -976,6 +976,46 @@ for (const { relayRole, staysGuest } of [ }); } +test("a saved guest whom a slow relay lists as owner is corrected in the open channel", async ({ + page, +}) => { + // Hold the relay's role answer so #welcome-everyone is already open when it + // arrives; the open view must drop guest mode without a reload. + await page.addInitScript(() => { + const internals = window.__TAURI_INTERNALS__ ?? {}; + let wrapped: unknown; + Object.defineProperty(internals, "invoke", { + configurable: true, + get: () => wrapped, + set: (fn: (command: string, ...rest: unknown[]) => Promise) => { + wrapped = async (command: string, ...rest: unknown[]) => { + if (command === "relay_requires_membership") { + await new Promise((resolve) => setTimeout(resolve, 4_000)); + } + return fn(command, ...rest); + }; + }, + }); + window.__TAURI_INTERNALS__ = internals; + }); + await openWelcomeEveryoneAsGuest(page, { + relayAgents: [resetWorker()], + relayRequiresMembership: true, + relayRole: "owner", + }); + expect( + await page.evaluate((key) => localStorage.getItem(key), GUEST_STORAGE_KEY), + ).toBe("true"); + + await expect( + page.getByTestId("welcome-composer-persona-mention"), + ).toBeVisible({ timeout: 15_000 }); + await expect(guestHint(page)).toHaveCount(0); + expect( + await page.evaluate((key) => localStorage.getItem(key), GUEST_STORAGE_KEY), + ).toBe(null); +}); + test("in #welcome-everyone a guest who can see no agents gets no hint", async ({ page, }) => { From d532c53f823a9fe1d88bcf7b25e6d36bca5ab3da Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 15:30:42 -0500 Subject: [PATCH 24/28] fix(buzz-acp): escape guest text in conversation context and event metadata b5dd76fa0 escaped message content and the author label inside ``, but other guest-written text still reached semantic section bodies raw. `semantic_section*` keeps bodies verbatim by design, so escaping is the caller's job, and five callers skipped it: - `format_conversation_context`: earlier replies (`msg.content`). A guest's thread reply containing `` is replayed into every later turn's context, closes the untrusted section early, and the rest reads as trusted framing. Same for `` in DMs. - `format_prompt_actor`: the participant's own display name, or a raw `p`-tag value. Used by the context lines and by `mentions=[...]` in the event block, so a guest who names themselves `...` and self-mentions got past b5dd76fa0. - `format_event_block` `Tags:` line: serde_json escapes `"` and `\`, not `<`/`>`, and every tag value is signer-chosen. - Both `channel_display`s: the channel name, from the same metadata event whose `about` is already escaped. - `collapse_prompt_line`: project name/slug/owner/coordinate/repo in ``; it has no other callers. All now go through `prompt_framing::escape_semantic_text`. Two new tests pin this; both fail on the old code (3 and 8 closing tags where 1 is expected). Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/queue.rs | 145 +++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 8 deletions(-) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index f8e7eff90d9..523a3e50616 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1317,11 +1317,15 @@ fn resolve_prompt_label( }) } +/// Render a participant for a semantic section body, escaped: the label is the +/// participant's own profile name and the pubkey may be a raw `p`-tag value, +/// so both are attacker-chosen (see [`format_event_block`]). fn format_prompt_actor(pubkey: &str, profile_lookup: Option<&PromptProfileLookup>) -> String { - match resolve_prompt_label(pubkey, profile_lookup) { + let actor = match resolve_prompt_label(pubkey, profile_lookup) { Some(label) => format!("{label} ({pubkey})"), None => pubkey.to_string(), - } + }; + crate::prompt_framing::escape_semantic_text(&actor) } /// Format the per-event `[Event]` block for a single [`BatchEvent`]. @@ -1351,7 +1355,10 @@ pub(crate) fn format_event_block( let event_id = be.event.id.to_hex(); let channel_display = match channel_info { - Some(ci) => format!("{} (#{channel_id})", ci.name), + Some(ci) => format!( + "{} (#{channel_id})", + crate::prompt_framing::escape_semantic_text(&ci.name) + ), None => channel_id.to_string(), }; @@ -1393,10 +1400,14 @@ pub(crate) fn format_event_block( Content: {escaped_content}", ); - // Always include tags — they carry structural information. + // Always include tags — they carry structural information. Tag values are + // signer-chosen too, and JSON encoding leaves `<`/`>` alone. let tags_json: Vec<&[String]> = be.event.tags.iter().map(|t| t.as_slice()).collect(); if let Ok(tags_str) = serde_json::to_string(&tags_json) { - block.push_str(&format!("\nTags: {tags_str}")); + block.push_str(&format!( + "\nTags: {}", + crate::prompt_framing::escape_semantic_text(&tags_str) + )); } // Parsed structural fields. @@ -1519,6 +1530,7 @@ fn resolve_reply_anchor( const MAX_DESCRIPTION_LEN: usize = 500; const MAX_PROJECT_NAME_LEN: usize = 160; +/// Collapse untrusted metadata to one bounded line, escaped for a `` body. fn collapse_prompt_line(raw: &str, max_chars: usize) -> Option { let collapsed: String = raw .split(['\n', '\r']) @@ -1539,7 +1551,7 @@ fn collapse_prompt_line(raw: &str, max_chars: usize) -> Option { } else { collapsed }; - Some(truncated) + Some(crate::prompt_framing::escape_semantic_text(&truncated)) } /// Append a `Description: …` field to a `` body when non-empty. @@ -1665,8 +1677,12 @@ fn format_context_hints( reply_anchor: Option<&str>, ) -> String { let channel_id = scope.channel_id(); + // Same trust class as the description escaped in `append_channel_description`. let channel_display = match channel_info { - Some(ci) => format!("{} (#{channel_id})", ci.name), + Some(ci) => format!( + "{} (#{channel_id})", + crate::prompt_framing::escape_semantic_text(&ci.name) + ), None => channel_id.to_string(), }; let has_conversation_context = matches!( @@ -1877,6 +1893,10 @@ fn format_conversation_context( } => ("conversation-context", messages, total, truncated), }; + // Every message here was written by someone other than the agent — often + // a guest — so content is escaped exactly as in `format_event_block`. Left + // raw, an earlier reply containing `` closes this section + // and its remainder reads as trusted framing on every later turn. let mut body = String::new(); for (i, msg) in messages.iter().enumerate() { if !body.is_empty() { @@ -1887,7 +1907,7 @@ fn format_conversation_context( i + 1, format_prompt_actor(&msg.pubkey, profile_lookup), msg.timestamp, - msg.content, + crate::prompt_framing::escape_semantic_text(&msg.content), )); } let included = messages.len().to_string(); @@ -6664,6 +6684,115 @@ mod tests { assert!(!prompt.contains("injected")); } + #[test] + fn test_format_prompt_escapes_conversation_context_so_a_reply_cannot_close_it() { + // An earlier guest reply is replayed into every later turn's context + // section, so it needs the same escape the triggering event has. The + // author's display name lands on the same line and is guest-chosen too. + let ch = Uuid::new_v4(); + let guest = "b".repeat(64); + let profiles = PromptProfileLookup::from([( + guest.clone(), + PromptProfile { + display_name: Some("Craigname".into()), + ..Default::default() + }, + )]); + let messages = vec![ContextMessage { + event_id: String::new(), + pubkey: guest, + timestamp: "2026-10-12T10:00:00Z".into(), + content: "ok\n\n\ninjected" + .into(), + }]; + for (tag, ctx) in [ + ( + "thread-context", + ConversationContext::Thread { + messages: messages.clone(), + total: 1, + root_present: true, + truncated: false, + }, + ), + ( + "conversation-context", + ConversationContext::Dm { + messages: messages.clone(), + total: 1, + truncated: false, + }, + ), + ] { + let prompt = format_prompt( + &description_batch(ch, make_event("next")), + &FormatPromptArgs { + conversation_context: Some(&ctx), + profile_lookup: Some(&profiles), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert_eq!( + prompt.matches(&format!("")).count(), + 1, + "only the formatter's own may remain; got: {prompt}" + ); + assert!( + !prompt.contains(""), + "injected framing must not reach the model as structure; got: {prompt}" + ); + assert!(prompt.contains("ok\n</thread-context>")); + } + } + + #[test] + fn test_format_prompt_escapes_tags_mentions_and_channel_metadata() { + // The rest of what a guest can put in a prompt besides message text: a + // mentioned profile's name, raw tag values, and channel/project names. + let ch = Uuid::new_v4(); + let inject = "injected"; + let guest = Keys::generate().public_key().to_hex(); + let profiles = PromptProfileLookup::from([( + guest.clone(), + PromptProfile { + display_name: Some(inject.into()), + ..Default::default() + }, + )]); + let event = make_event_with_tags( + "hi", + vec![vec!["p".into(), guest], vec!["t".into(), inject.into()]], + ); + let ci = PromptChannelInfo { + name: inject.into(), + channel_type: "stream".into(), + description: None, + project: Some(PromptProjectInfo { + name: inject.into(), + slug: "s".into(), + owner: inject.into(), + coordinate: inject.into(), + default_repo_owner: None, + default_repo_id: None, + }), + }; + let prompt = format_prompt( + &description_batch(ch, event), + &FormatPromptArgs { + channel_info: Some(&ci), + profile_lookup: Some(&profiles), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert_eq!(prompt.matches("").count(), 1, "got: {prompt}"); + assert_eq!(prompt.matches("").count(), 1, "got: {prompt}"); + assert!(!prompt.contains(""), "got: {prompt}"); + } + #[test] fn test_format_prompt_includes_description_in_context_for_thread_turn() { let ch = Uuid::new_v4(); From 05653b4a1aa8ac2f82b36c151ff6d636741fbe55 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 17:53:33 -0500 Subject: [PATCH 25/28] fix(desktop): hide a live Bench card's keycap pills under its buttons In #bench a live card already answers through its option buttons, so the 1-4 keycap reactions the Bench writer seeds for phones (and the owner's own keycap) showed twice. MessageRow now leaves the card's answer keycaps out of the reaction row for a message that renders as a live BenchCard; other reactions and the add-reaction button stay. Every other message renders its reactions exactly as before, and nothing sent to the relay changes, so phones still see and tap the pills. Co-Authored-By: Claude Opus 5.5 --- .../features/bench/lib/benchCards.test.mjs | 17 +++++ desktop/src/features/bench/lib/benchCards.ts | 12 +++ .../src/features/messages/ui/MessageRow.tsx | 14 +++- desktop/tests/e2e/bench-screen.spec.ts | 76 ++++++++++++++++++- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/bench/lib/benchCards.test.mjs b/desktop/src/features/bench/lib/benchCards.test.mjs index d19dfb36da2..03219bcc474 100644 --- a/desktop/src/features/bench/lib/benchCards.test.mjs +++ b/desktop/src/features/bench/lib/benchCards.test.mjs @@ -16,6 +16,7 @@ import { benchValidityLabel, createPollGroup, indexBenchCards, + isBenchAnswerKeycap, isBenchCandidate, safeBenchLinks, } from "./benchCards.ts"; @@ -514,6 +515,22 @@ test("retired card: the Bench's answer still shows, read-only", () => { ); }); +test("keycaps: only the card's own answer keycaps, as the Bench reads them", () => { + const two = card(); + assert.equal(isBenchAnswerKeycap(two, "1️⃣"), true); + assert.equal(isBenchAnswerKeycap(two, "2️⃣"), true); + assert.equal(isBenchAnswerKeycap(two, "3️⃣"), false, "no option 3"); + assert.equal(isBenchAnswerKeycap(two, "1⃣"), false, "not the seeded form"); + assert.equal(isBenchAnswerKeycap(two, "\u{1F44D}"), false); + const five = card({ options: ["a", "b", "c", "d", "e"] }); + assert.equal(isBenchAnswerKeycap(five, "4️⃣"), true); + assert.equal(isBenchAnswerKeycap(five, "5️⃣"), false, "stops at 4"); + assert.equal( + isBenchAnswerKeycap(card({ kind: "status", options: [] }), "1️⃣"), + false, + ); +}); + test("deadline: RFC 3339 to unix seconds, null when unparseable", () => { assert.equal(benchDeadlineSeconds("2026-10-14T15:00:00Z"), 1_791_990_000); assert.equal(benchDeadlineSeconds("soon"), null); diff --git a/desktop/src/features/bench/lib/benchCards.ts b/desktop/src/features/bench/lib/benchCards.ts index 4b07d78ccee..b914f8ad097 100644 --- a/desktop/src/features/bench/lib/benchCards.ts +++ b/desktop/src/features/bench/lib/benchCards.ts @@ -452,6 +452,18 @@ export function benchRecordedLine( : line; } +/** The keycaps the Bench seeds and reads as answers (item.rs OPTION_EMOJI). */ +const BENCH_OPTION_KEYCAPS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"]; + +/** + * Whether a reaction is one of the card's answer keycaps: the pills phones + * tap, which the desktop card already shows as its option buttons. + */ +export function isBenchAnswerKeycap(card: BenchCard, emoji: string): boolean { + const index = BENCH_OPTION_KEYCAPS.indexOf(emoji); + return index !== -1 && index < card.options.length; +} + /** RFC 3339 deadline to unix seconds; null when it does not parse. */ export function benchDeadlineSeconds(deadline: string | null): number | null { if (!deadline) return null; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index e642e0e731b..b45d7d30528 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -12,6 +12,7 @@ import { } from "@/features/messages/lib/canSendToChannel"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { isBenchAnswerKeycap } from "@/features/bench/lib/benchCards"; import { BenchCard } from "@/features/bench/ui/BenchCard"; import { useBenchCardForMessage } from "@/features/bench/useBenchCards"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; @@ -385,6 +386,17 @@ export const MessageRow = React.memo( pending: message.pending, signerPubkey: message.signerPubkey, }); + // A live card's option buttons already show its answer keycaps. + const shownReactions = React.useMemo( + () => + benchCard + ? reactions.filter( + (reaction) => + !isBenchAnswerKeycap(benchCard.card, reaction.emoji), + ) + : reactions, + [benchCard, reactions], + ); const renderBody = () => { switch (message.kind) { @@ -700,7 +712,7 @@ export const MessageRow = React.memo( {continuationMetadataNode} { + // The mock's #general stands in for #bench. + const generalId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const cardId = "9".repeat(64); + await installMockBridge(page, { + benchCards: benchSnapshot(null, generalId, cardId), + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + }) === true, + ); + + await page.evaluate( + ({ cardId, owner, writer }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const react = (target: string, content: string, pubkey: string) => + emit?.({ + channelName: "general", + content, + extraTags: [["e", target]], + kind: 7, + pubkey, + }); + emit?.({ + channelName: "general", + content: "card", + id: cardId, + pubkey: writer, + }); + // The writer's seeded keycaps and the owner's tap, as phones see them. + react(cardId, "1️⃣", writer); + react(cardId, "2️⃣", writer); + react(cardId, "2️⃣", owner); + // The mock's "React to me with a custom emoji" message. + react("d".repeat(64), "1️⃣", owner); + react(cardId, "\u{1F44D}", owner); + }, + { cardId, owner: OWNER, writer: WRITER }, + ); + + const rows = page.getByTestId("message-row"); + const cardRow = rows.filter({ has: page.getByTestId("bench-card") }); + const plainRow = rows + .filter({ hasText: "React to me with a custom emoji" }) + .last(); + const pill = (row: typeof rows, emoji: string) => + row.getByRole("button", { name: `Toggle ${emoji} reaction` }); + await expect(cardRow).toContainText("Ship the 0.6 release?"); + await expect(pill(cardRow, "\u{1F44D}")).toBeVisible(); + await expect(pill(plainRow, "1️⃣")).toBeVisible(); + await expect(pill(cardRow, "1️⃣")).toHaveCount(0); + await expect(pill(cardRow, "2️⃣")).toHaveCount(0); +}); From 0abcaf5dca1b4c81a069962be766de58db9087be Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 12 Oct 2026 18:11:02 -0500 Subject: [PATCH 26/28] feat(bench): card summary as plain text, not a code box The phone drew the fenced summary as a grey monospace "text" box with a copy button. The summary is now its own paragraph of ordinary text that both apps show character for character. gpt_markdown 1.1.6 (pinned in mobile/pubspec.lock; source read from the pub.dev archive with the same sha256) honours no backslash escape, so nothing is escaped. prose() puts an invisible U+FEFF where it breaks each trigger instead. U+FEFF is whitespace to Dart's regex \s and to micromark's /\s/, so a U+FEFF before *, _ and ~ means none of them can close emphasis or strikethrough on either app. A U+FEFF after #, @, :, &, <, [, ] and \ stops headings, #channel and @mention pills, :emoji:, autolinks, entities, HTML, links, citations, LaTeX and escapes. Inside -- and || there is no line-end rule (phone) or spoiler (desktop). A line opening with a list, quote, table, setext, radio or rule mark gets one first. Backticks stay U+02CB. Spaces collapse, so nothing is indented. URLs stay readable but are not links; the validated link lines still are. Blank lines make the summary a paragraph of its own. The phone matches links and italics across lines, and it rewrites each link line's url to [url](url) first. Without the blank lines, a '[' in the summary paired with that rewrite and the phone dropped the text in between. CARD_FORMAT 4 reposts each live unanswered card once, quietly. fence(), wrap() and FENCE_WIDTH are gone. plain_text shares the new list_mark() helper. Checked outside cargo: the desktop's own react-markdown pipeline (nodeCache.ts with remarkGfm, remarkBreaks and the Buzz plugins) renders Rust-made sample cards with only p/br/strong/ol/li/a and the exact summary text. A node emulation of gpt_markdown 1.1.6's generate() and the app's normalizeBareLinks, with the regexes copied verbatim, shows only the title, the options, the paragraph breaks and the link line as markup. The raw text fails both checks. Flutter is not installed, so no widget test ran. Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/item.rs | 130 ++++++++++--------- desktop/src-tauri/src/bench/tests.rs | 41 ++---- desktop/src-tauri/src/bench/tests/answers.rs | 121 +++++++++++------ 3 files changed, 159 insertions(+), 133 deletions(-) diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 190007064d7..2d815d10ded 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -32,9 +32,10 @@ const LABEL_MAX: usize = 40; const URL_MAX: usize = 512; const SUMMARY_MAX: usize = 400; const BOARD_TITLE_MAX: usize = 60; -/// Columns of a card's fence a 360 dp phone shows without scrolling: about -/// 208 dp beside the avatar and code padding, at ~7.8 dp per 13 px glyph. -pub(crate) const FENCE_WIDTH: usize = 26; +/// U+FEFF, zero-width no-break space: invisible on both apps, and whitespace +/// to the phone's regexes (Dart `\s`) and the desktop's emphasis rules +/// (micromark's `/\s/`). +const ZWNBSP: char = '\u{FEFF}'; /// A drop file exactly as a writer may author it. `deny_unknown_fields` is /// the whole "a drop never carries answer/verdict/source/version/card/ @@ -170,7 +171,7 @@ pub(crate) struct Item { } /// `^[a-z0-9][a-z0-9-]{0,max-1}$`: writer, id and area. Writer and area, -/// like a validated link url, print outside the fence as they are. +/// like a validated link url, print on the card as they are. pub(crate) fn valid_name(s: &str, max: usize) -> bool { let plain = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit(); !s.is_empty() @@ -184,7 +185,7 @@ fn valid_text(s: &str, max_chars: usize, field: &str) -> Result<(), String> { return Err(format!("{field}: longer than {max_chars} chars")); } // Cc except newline and tab, plus the zero-width and bidi format - // characters both apps honour inside a fence: an option list reordered + // characters both apps honour in card text: an option list reordered // on screen steers the one tap the board exists for. let bad = |c: char| { (c.is_control() && c != '\n' && c != '\t') @@ -251,8 +252,8 @@ fn valid_link(l: &Link) -> Result<(), String> { l.label )); } - // The card prints the raw url outside the fence, but the parser below - // drops tabs and newlines and encodes spaces: bound the raw bytes first. + // The card prints the raw url, but the parser below drops tabs and + // newlines and encodes spaces: bound the raw bytes first. if !l.url.chars().all(|c| c.is_ascii_graphic() && c != '@') { return Err(format!( "link url {:?}: printable ascii without spaces or '@' only", @@ -350,7 +351,7 @@ pub(crate) fn validate( return Err("private items ship in Phase 3".to_string()); } // Kept in canonical form: chrono also takes a space separator and any - // number of fractional digits, and the card prints it outside the fence. + // number of fractional digits, and the card prints it as it is. let deadline = d .deadline .as_deref() @@ -423,14 +424,6 @@ fn short_hash(bytes: &[u8]) -> String { h } -/// Wrap text in a code fence longer than any backtick run inside it. Both -/// apps render fenced code inertly: no links, mentions or markdown. -pub(crate) fn fence(text: &str) -> String { - let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); - let f = "`".repeat((longest + 1).max(3)); - format!("{f}text\n{text}\n{f}") -} - pub(crate) fn summary(body: &str) -> String { match body.char_indices().nth(SUMMARY_MAX) { Some((cut, _)) => format!("{}\u{2026}", &body[..cut]), @@ -438,35 +431,56 @@ pub(crate) fn summary(body: &str) -> String { } } -/// Hard-wrap each line at `width` chars on spaces, cutting longer words; -/// runs of spaces and tabs become one space and newlines stay. The phone -/// never wraps a fence: each line is one row it scrolls sideways. -pub(crate) fn wrap(text: &str, width: usize) -> String { - let mut lines = Vec::new(); - for line in text.split('\n') { - let (mut row, mut len) = (String::new(), 0); - for word in line.split_whitespace() { - let chars: Vec = word.chars().collect(); - for piece in chars.chunks(width) { - if len > 0 && len + 1 + piece.len() > width { - lines.push(std::mem::take(&mut row)); - len = 0; - } - if len > 0 { - row.push(' '); - len += 1; - } - row.extend(piece); - len += piece.len(); +/// Agent text as plain paragraphs both apps show character for character: +/// gpt_markdown 1.1.6 (the phone) honours no backslash escape, so a ZWNBSP +/// breaks each trigger instead. Before `*`, `_` and `~` it makes them unable +/// to close emphasis or strikethrough and splits `**`, `~~` and `~~~`. After +/// `#`, `@`, `:`, `&`, `<`, `[`, `]` and `\` there is no heading, #channel, +/// @mention, :emoji:, autolink, entity, HTML, link, citation, LaTeX or escape. +/// Inside `--` and `||` there is no line-end rule (phone) or spoiler +/// (desktop), and before a line opening with a list, quote, table, setext, +/// radio or rule mark, no block. A ZWNBSP cannot split a code span, so a +/// backtick becomes U+02CB, which looks the same. Spaces and tabs collapse, +/// so no line is indented and none ends in a hard break. +pub(crate) fn prose(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for (i, line) in text.trim().split('\n').enumerate() { + if i > 0 { + out.push('\n'); + } + let line = line.split_whitespace().collect::>().join(" "); + if line.starts_with(['-', '+', '=', '>', '(', '|', '\u{2E3B}']) + || list_mark(&line).is_some() + { + out.push(ZWNBSP); + } + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '`' => out.push('\u{02CB}'), + '*' | '_' | '~' => out.extend([ZWNBSP, c]), + '#' | '@' | ':' | '&' | '<' | '[' | ']' | '\\' => out.extend([c, ZWNBSP]), + '-' | '|' if chars.peek() == Some(&c) => out.extend([c, ZWNBSP]), + c => out.push(c), } } - lines.push(row); } - lines.join("\n") + out } -/// Text outside any fence: board lines and a card's title, options and link -/// labels. Both apps render markdown there and the phone has no backslash +/// Digits of a leading `1.` or `1)` list mark: an ordered list item on the +/// desktop, and on the phone too when it is `1.`. +fn list_mark(s: &str) -> Option { + let digits = s.bytes().take_while(u8::is_ascii_digit).count(); + let b = s.as_bytes(); + (digits > 0 + && matches!(b.get(digits), Some(b'.' | b')')) + && matches!(b.get(digits + 1), None | Some(b' '))) + .then_some(digits) +} + +/// Board lines and a card's title, options and link labels: words only. +/// Both apps render markdown there and the phone has no backslash /// escape, so anything outside a plainly inert set becomes a space: `_` /// pairs into emphasis on the desktop, `;` closes an entity (`#`) the /// desktop decodes into a live `#channel` or `buzz://` link, and a space @@ -496,12 +510,7 @@ pub(crate) fn plain_text(t: &str, max: usize) -> String { let mut s = words .trim_start_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '"' || c == '\'')) .to_string(); - let digits = s.bytes().take_while(u8::is_ascii_digit).count(); - let b = s.as_bytes(); - if digits > 0 - && matches!(b.get(digits), Some(b'.' | b')')) - && matches!(b.get(digits + 1), None | Some(b' ')) - { + if let Some(digits) = list_mark(&s) { s.remove(digits); } let s: String = s.chars().take(max).collect(); @@ -550,8 +559,9 @@ fn state_name(s: StatusState) -> &'static str { /// as stale). An answered card gets the new text through its Recorded edit /// and keeps its answer and its 24 h keep. 3: title and options moved out of /// the fence, which the phone draws as unwrapped monospace, and the summary -/// left in it is hard-wrapped. -pub(crate) const CARD_FORMAT: u32 = 3; +/// left in it is hard-wrapped. 4: the summary leaves the fence, a grey code +/// box on the phone, for its own paragraph of prose(). +pub(crate) const CARD_FORMAT: u32 = 4; /// The version a card is posted and tagged at: `.`. /// Cards posted before CARD_FORMAT existed carry the bare Drop hash. @@ -559,11 +569,11 @@ pub(crate) fn card_hash(item: &Item) -> String { format!("{}.{CARD_FORMAT}", item.hash) } -/// Card text: marker line, the title in bold, the summary alone in a fence -/// (agent prose stays inert) wrapped to FENCE_WIDTH, the options as a -/// numbered list, then the validated deadline and links so the phone can -/// open them, and on an unanswered pending card the line that says how to -/// answer. Everything outside the fence but the url goes through plain_text. +/// Card text: marker line, the title in bold, the summary as its own +/// paragraph of prose() (agent text stays inert), the options as a numbered +/// list, then the validated deadline and links so the phone can open them, +/// and on an unanswered pending card the line that says how to answer. +/// Everything but the summary and the url goes through plain_text. pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { let mut out = format!( "{} \u{b7} {} \u{b7} {}", @@ -577,13 +587,13 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out.push_str(&format!("\n**{title}**")); } if !item.body.trim().is_empty() { - // No backtick survives, so the fence is always three: the phone ends - // a block at the first line of exactly three backticks, never at a - // longer fence, and the text after it would render live. U+02CB - // looks the same and is markdown on neither app. - let body = summary(&item.body).replace('`', "\u{02CB}"); + // Blank lines make it a paragraph of its own on both apps, so nothing + // in it pairs with a `*` or `](` on the card's other lines: the phone + // matches links and italics across lines, and rewrites each link + // line's url to `[url](url)` first. + out.push_str("\n\n"); + out.push_str(&prose(&summary(&item.body))); out.push('\n'); - out.push_str(&fence(&wrap(&body, FENCE_WIDTH))); } if item.kind == ItemKind::Pending { for (i, o) in item.options.iter().enumerate() { @@ -604,7 +614,7 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out.push_str("\nTap one number below to answer."); } out.push_str(&format!("\nas of {hhmm}")); - // Outside the fence, so through plain_title like every board line. + // Through plain_title like every board line. if let Some(a) = &item.answer { if let Some(o) = item.options.get(a.option) { out.push_str(&format!( diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 23a485182d0..6b9f301051d 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -304,27 +304,6 @@ fn version_hash_ignores_key_order_id_and_updated_at() { assert_ne!(a.hash, c.hash); } -#[test] -fn fence_outgrows_the_longest_backtick_run() { - for (text, len) in [ - ("plain", 3), - ("a ``` b", 4), - ("a ````` b", 6), - ("``x`` ```` `", 5), - ] { - let out = fence(text); - let f = "`".repeat(len); - let inner = out - .strip_prefix(&format!("{f}text\n")) - .unwrap() - .strip_suffix(&format!("\n{f}")) - .unwrap(); - assert_eq!(inner, text); - let longest = inner.split(|c| c != '`').map(str::len).max().unwrap(); - assert!(longest < len, "{text:?}"); - } -} - #[test] fn summary_cuts_on_a_char_boundary() { assert_eq!(summary("short"), "short"); @@ -349,9 +328,9 @@ fn render_card_golden() { render_card(&item, "14:05"), "🔴 · dreamforge · orchestrator\n\ **Relay choice**\n\ - ```text\n\ + \n\ Stay or move?\n\ - ```\n\ + \n\ 1. Stay\n\ 2. Move\n\ \n\ @@ -369,23 +348,23 @@ fn render_card_golden() { .unwrap(); assert_eq!( render_card(&decision, "14:05"), - "🧭 · dreamforge · orchestrator\n**Relay choice**\n```text\nStay or move?\n```\nas of 14:05" + "🧭 · dreamforge · orchestrator\n**Relay choice**\n\nStay or move?\n\nas of 14:05" ); } #[test] -fn render_card_keeps_mentions_inside_the_fence() { +fn render_card_keeps_mentions_inert() { // validate() refuses these strings; a card built from state must still - // keep them where neither app resolves them. The title is outside, so - // it loses its '@'; a bare nostr: is plain text on both apps. + // show them where neither app resolves them. The title loses its '@'; + // the summary keeps it, a U+FEFF after it and after the ':'. let mut item = check(&[]).unwrap(); item.title = "@Honey nostr:npub1abc".to_string(); item.body = "ping @Honey nostr:npub1abc".to_string(); let card = render_card(&item, "14:05"); - let (head, rest) = card.split_once("```text\n").unwrap(); - let (inner, tail) = rest.split_once("\n```").unwrap(); - assert_eq!(inner, "ping @Honey nostr:npub1abc"); - assert!(head.ends_with("\n**Honey nostr: npub1abc**\n"), "{head:?}"); + let (head, rest) = card.split_once("\n\n").unwrap(); + let (inner, tail) = rest.split_once("\n\n").unwrap(); + assert_eq!(inner, "ping @\u{FEFF}Honey nostr:\u{FEFF}npub1abc"); + assert!(head.ends_with("\n**Honey nostr: npub1abc**"), "{head:?}"); for outside in [head, tail] { assert!(!outside.contains('@'), "{outside:?}"); } diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 7de70e2ae12..e2537f01af0 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -139,10 +139,9 @@ fn render_card_recorded_trailer() { plain.replace(&format!("\n{HINT}"), "") ) ); - let tail = card.rsplit_once("\n```").unwrap().1; assert!( - !tail.contains("https://") && !tail.contains('['), - "{tail:?}" + !card.contains("https://") && !card.contains('['), + "{card:?}" ); item.answer = None; assert_eq!(render_card(&item, "14:05"), plain); @@ -620,8 +619,9 @@ fn answer_hint_only_on_unanswered_pending_cards() { #[tokio::test] async fn older_card_format_is_reposted_once_and_answers_survive() { - // Before CARD_FORMAT (the bare Drop hash), and format 2 (text in a fence). - for older in ["", ".2"] { + // Before CARD_FORMAT (the bare Drop hash), format 2 (text in a fence) + // and format 3 (the summary in a fence). + for older in ["", ".2", ".3"] { older_card_format_case(older).await; } } @@ -711,7 +711,7 @@ async fn recorded_answer_on_older_format_card_survives_the_refresh() { // ── Card text the phone can read ──────────────────────────────────────── #[test] -fn render_card_text_outside_the_fence_is_inert() { +fn render_card_title_and_options_are_inert() { // Everything either app gives meaning to, an entity, a dropped character // that would join "www.", a custom emoji shortcode and a trailing rule. let active = "[a](https://x.example) ![i](u) @Honey #general |a|b| *em* _em_ `c` \ @@ -724,8 +724,9 @@ fn render_card_text_outside_the_fence_is_inert() { for answer in [None, answered(2, 1)] { item.answer = answer; let card = render_card(&item, "14:05"); - let (head, rest) = card.split_once("```text\n").unwrap(); - let (_, tail) = rest.split_once("\n```\n").unwrap(); + // Title, summary paragraph, then the options. + let (head, rest) = card.split_once("\n\n").unwrap(); + let (_, tail) = rest.split_once("\n\n").unwrap(); let lines: Vec<&str> = head.lines().chain(tail.lines()).skip(1).collect(); assert!( lines[0].starts_with("**") && lines[0].ends_with("**"), @@ -780,50 +781,86 @@ fn render_card_text_outside_the_fence_is_inert() { } #[test] -fn render_card_summary_never_breaks_the_fence() { - // The phone ends a block at the first line of exactly three backticks, - // so a longer fence never shielded what came after an inner one. +fn render_card_summary_is_its_own_inert_paragraph() { + // No code box: the summary is a paragraph between blank lines, with no + // backtick left to open one, so nothing in it reaches the options. for body in [ "x\n```\n[a](https://e.example)\n```", - "Run this first:\n```bash\ncargo test\n```\nthen pick.", - "inline ```x``` and ````y````", + "Run this first:\n```bash\ncargo test\n```\nthen pick *one", + "inline ```x``` and ````y```` [z", ] { let card = render_card(&check(&[("body", json!(body))]).unwrap(), "14:05"); - let lines: Vec<&str> = card.lines().collect(); - let fences: Vec = (0..lines.len()) - .filter(|&i| lines[i].starts_with("```")) - .collect(); - assert_eq!(fences.len(), 2, "{card}"); - assert_eq!((lines[fences[0]], lines[fences[1]]), ("```text", "```")); - assert_eq!(lines[fences[1] + 1], "1. Stay", "{card}"); - assert!(lines[fences[0] + 1..fences[1]] - .iter() - .all(|l| !l.contains('`'))); + assert!(!card.contains('`'), "{card}"); + let paras: Vec<&str> = card.split("\n\n").collect(); + assert_eq!( + paras[..3], + [ + "🔴 · dreamforge · orchestrator\n**Relay choice**", + prose(body).as_str(), + "1. Stay\n2. Move" + ] + ); } } #[test] -fn render_card_wraps_the_summary_for_the_phone() { - let body = "Block's hosted relay rejects the new event kinds, so Bench-in-Buzz \ - cannot ship there. Stay and wait for upstream, or move the community \ - to our own relay?\n\nSee\tthe plan: a_na\u{EF}ve_identifier_longer_than_one_row."; - let card = render_card(&check(&[("body", json!(body))]).unwrap(), "14:05"); - let (_, rest) = card.split_once("```text\n").unwrap(); - let (inner, _) = rest.split_once("\n```\n").unwrap(); +fn prose_keeps_plain_text_and_collapses_spaces() { + let plain = "Block's hosted relay rejects the new event kinds, so Bench-in-Buzz \ + cannot ship there. Stay and wait for upstream (a week, maybe 2), \ + or move the community to our own relay?\n\n\ + Either way - it's 3.5 hours of work!"; + assert_eq!(prose(plain), plain); + let card = render_card(&check(&[("body", json!(plain))]).unwrap(), "14:05"); assert!( - inner.lines().all(|l| l.chars().count() <= FENCE_WIDTH), - "{inner}" - ); - // Only the extra whitespace goes; the blank line stays. - let squash = |s: &str| s.split_whitespace().collect::(); - assert_eq!(squash(inner), squash(body)); - assert!(inner.contains("\n\n"), "{inner}"); - assert_eq!(wrap("aaa bbb ccc", 7), "aaa bbb\nccc"); - assert_eq!(wrap("a b\n\n\t c ", 26), "a b\n\nc"); - assert_eq!( - wrap(&"x".repeat(30), 26), - format!("{}\nxxxx", "x".repeat(26)) + card.contains(&format!("**\n\n{plain}\n\n1. Stay")), + "{card}" ); + // No indent (a code block or nested item) and no trailing hard break. + assert_eq!(prose("\n a \t b \n\n\t c \n"), "a b\n\nc"); +} + +#[test] +fn prose_breaks_each_trigger() { + // gpt_markdown 1.1.6 honours no backslash escape, so each trigger either + // side gives meaning to is broken by an invisible U+FEFF instead. + let z = |s: &str| s.replace('°', "\u{FEFF}"); + for (raw, want) in [ + // Emphasis and strikethrough cannot close; `**`, `~~`, `~~~` split. + ("*em* and **b**", "°*em°* and °*°*b°*°*"), + ("_em_ and __b__", "°_em°_ and °_°_b°_°_"), + ("~s~ and ~~s~~", "°~s°~ and °~°~s°~°~"), + // Code: U+02CB, since a U+FEFF cannot split a code span. + ("`c` and ```f```", "ˋcˋ and ˋˋˋfˋˋˋ"), + // Heading, #channel, @mention, email, :emoji:, autolinks, entities. + ("# h #general", "#° h #°general"), + ("@Honey a@b.example", "@°Honey a@°b.example"), + ( + ":party: https://github.com/x", + ":°party:° https:°//github.com/x", + ), + ( + "#ch u ", + "&°num;ch <°u>u<°/u> <°https:°//e.x>", + ), + // Links, images, citations, footnotes, LaTeX and escapes. + ("[a](u) ![i](u) [1] [^n]", "[°a]°(u) ![°i]°(u) [°1]° [°^n]°"), + ("\\(x\\) \\[y\\] \\*", "\\°(x\\°) \\°[°y\\°]° \\°°*"), + // A line ending in --- is a rule on the phone; || a desktop spoiler. + ("wait --- or ||s||", "wait -°-°- or |°|s|°|"), + // Block marks at a line start. + ( + "- a\n+ b\n> c\n1. d\n12) e", + "°- a\n°+ b\n°> c\n°1. d\n°12) e", + ), + ("(x) r\n===\n\u{2E3B} x", "°(x) r\n°===\n°\u{2E3B} x"), + ("| a | b |\n|---|---|", "°| a | b |\n°|-°-°-|-°-°-|"), + ] { + let out = prose(raw); + assert_eq!(out, z(want), "{raw:?}"); + // Both apps show exactly the text that came in. + let shown = out.replace('\u{FEFF}', "").replace('\u{02CB}', "`"); + assert_eq!(shown, raw); + } } #[test] From d9c55a5ba88fa6f75612af75318c9f5e9a844016 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 08:54:35 -0500 Subject: [PATCH 27/28] fix(desktop): no description line on a project home header The line under the title shifted it up 8px, so the project home context panel no longer lined up with it (project-empty-state-alignment e2e). Co-Authored-By: Claude Opus 5.5 --- .../channels/ui/ChannelScreenHeader.tsx | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 6b2588c4707..d9c26fb7fb4 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -27,6 +27,7 @@ import { import { ownsAnyAgent } from "@/features/agents/knownAgentPubkeys"; import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { WELCOME_TEAM_ID } from "@/features/onboarding/welcomeGuide"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserNameIndicators } from "@/features/user-status/ui/UserNameIndicators"; import { Button } from "@/shared/ui/button"; @@ -110,9 +111,13 @@ export function ChannelScreenHeader({ !activeChannel.archivedAt && onJoinChannel; + // A project home shows its own context beside the header, aligned with a + // one-line title, so it gets no description line. + const isProjectHome = useIsProjectHomeChannel(activeChannel?.id); // Only an undescribed channel needs the roster, to decide whether this // viewer gets "Add a description". The channel screen already loads it. const needsManageCheck = + !isProjectHome && onAddDescription !== undefined && activeChannel !== null && activeChannel.channelType !== "dm" && @@ -128,25 +133,24 @@ export function ChannelScreenHeader({ needsManageCheck, ); const headerLine = getChannelHeaderLine(activeChannel, canManageChannel); - const subtitle = - headerLine.kind === "description" ? ( -

- {headerLine.text} -

- ) : headerLine.kind === "add-description" && onAddDescription ? ( - - ) : null; + const subtitle = isProjectHome ? null : headerLine.kind === "description" ? ( +

+ {headerLine.text} +

+ ) : headerLine.kind === "add-description" && onAddDescription ? ( + + ) : null; const terminalPanel = useTerminalPanel(); const managedAgentsQuery = useManagedAgentsQuery(); From b9247039aec347b6a4a41dfddb530baaacdaefe8 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 09:03:32 -0500 Subject: [PATCH 28/28] fix(desktop): show the channel description beside the title Under the title it moved the title up 8px, breaking upstream's header alignment (sidebar search and project home, sidebar.spec and project-empty-state-alignment e2e). Beside it, the title keeps its place. Reverts the project-home-only workaround from d9c55a5ba. Co-Authored-By: Claude Opus 5.5 --- .../channels/ui/ChannelScreenHeader.tsx | 42 +++++++++---------- desktop/src/features/chat/ui/ChatHeader.tsx | 25 ++++------- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index d9c26fb7fb4..1da9d385827 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -27,7 +27,6 @@ import { import { ownsAnyAgent } from "@/features/agents/knownAgentPubkeys"; import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { WELCOME_TEAM_ID } from "@/features/onboarding/welcomeGuide"; -import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserNameIndicators } from "@/features/user-status/ui/UserNameIndicators"; import { Button } from "@/shared/ui/button"; @@ -111,13 +110,9 @@ export function ChannelScreenHeader({ !activeChannel.archivedAt && onJoinChannel; - // A project home shows its own context beside the header, aligned with a - // one-line title, so it gets no description line. - const isProjectHome = useIsProjectHomeChannel(activeChannel?.id); // Only an undescribed channel needs the roster, to decide whether this // viewer gets "Add a description". The channel screen already loads it. const needsManageCheck = - !isProjectHome && onAddDescription !== undefined && activeChannel !== null && activeChannel.channelType !== "dm" && @@ -133,24 +128,25 @@ export function ChannelScreenHeader({ needsManageCheck, ); const headerLine = getChannelHeaderLine(activeChannel, canManageChannel); - const subtitle = isProjectHome ? null : headerLine.kind === "description" ? ( -

- {headerLine.text} -

- ) : headerLine.kind === "add-description" && onAddDescription ? ( - - ) : null; + const subtitle = + headerLine.kind === "description" ? ( +

+ {headerLine.text} +

+ ) : headerLine.kind === "add-description" && onAddDescription ? ( + + ) : null; const terminalPanel = useTerminalPanel(); const managedAgentsQuery = useManagedAgentsQuery(); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 377f6a8d5fa..7588da24362 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -33,7 +33,7 @@ type ChatHeaderProps = { mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; overlaysContent?: boolean; statusBadge?: React.ReactNode; - /** One line under the title, such as the channel description. */ + /** One muted line beside the title, such as the channel description. */ subtitle?: React.ReactNode; /** Identity adornment rendered exactly 4px after a DM title. */ titleAdornment?: React.ReactNode; @@ -129,14 +129,7 @@ export function ChatHeader({ >
- {/* With a subtitle, the title and subtitle rows (h-5 + h-4) share - the same h-9 row, so the header height does not change. */} -
+
{leadingContent ?? (

void handleCopyTitle()} size="icon-xs" @@ -177,10 +168,12 @@ export function ChatHeader({ {statusBadge}

) : null} + {/* Beside the title, not under it, so the title keeps its + vertical position (sidebar search and project home align to it). */} + {subtitle ? ( +
{subtitle}
+ ) : null}
- {subtitle ? ( -
{subtitle}
- ) : null}