diff --git a/bin/anthropic-first-acp b/bin/anthropic-first-acp new file mode 100755 index 000000000..729d32cc6 --- /dev/null +++ b/bin/anthropic-first-acp @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -u + +anthropic_file="${ANTHROPIC_CREDENTIALS_FILE:-$HOME/Downloads/jarvis-keys.env}" +openai_file="${EKO_CREDENTIALS_FILE:-$HOME/.config/eko/credentials.env}" + +if [[ -r "$anthropic_file" ]]; then + anthropic_value=$(sed -n 's/^ANTHROPIC_API_KEY=//p' "$anthropic_file" | head -1) + [[ -n "$anthropic_value" ]] && export ANTHROPIC_API_KEY="$anthropic_value" +fi + +export PATH="/home/mrmoe28/.npm-packages/bin:/home/mrmoe28/.local/bin:$PATH" +export CODEX_HOME="${CODEX_HOME:-$HOME/.buzz-codex}" +mkdir -p "$CODEX_HOME" + +# Run Anthropic directly. Provider failover requires a multi-agent ACP change; +# switching executables inside one ACP stream is not protocol-safe. +exec claude-agent-acp diff --git a/bin/codex-acp-buzz b/bin/codex-acp-buzz new file mode 100755 index 000000000..42d8fca89 --- /dev/null +++ b/bin/codex-acp-buzz @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keep Buzz's existing OPENAI_API_KEY untouched. Codex ACP needs the separate +# OpenAI credential managed by the local Eko tooling. +credentials_file="${EKO_CREDENTIALS_FILE:-$HOME/.config/eko/credentials.env}" +if [[ -r "$credentials_file" ]]; then + saved_openai_api_key="${OPENAI_API_KEY-}" + openai_value=$(sed -n 's/^OPENAI_API_KEY=//p' "$credentials_file" | head -1) + [[ -n "$openai_value" ]] && export OPENAI_API_KEY="$openai_value" + if [[ -z "${OPENAI_API_KEY-}" ]]; then + export OPENAI_API_KEY="$saved_openai_api_key" + fi +fi + +# Buzz may launch agents in a restricted environment where the normal Codex +# state directory is read-only. Keep a separate writable state directory for +# this ACP child. +export CODEX_HOME="${CODEX_HOME:-$HOME/.buzz-codex}" +mkdir -p "$CODEX_HOME" + +exec codex-acp "$@" diff --git a/bin/youtube-browser-mcp.mjs b/bin/youtube-browser-mcp.mjs new file mode 100755 index 000000000..8ef108cca --- /dev/null +++ b/bin/youtube-browser-mcp.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import readline from "node:readline"; +import { chromium } from "/home/mrmoe28/Downloads/eko-capture/node_modules/playwright/index.mjs"; + +let browser, page; +const tools = [ + { name: "browser_tabs", description: "List open browser tabs", inputSchema: { type:"object", properties:{} } }, + { name: "browser_navigate", description: "Navigate the active tab to a URL", inputSchema: { type:"object", properties:{ url:{type:"string"} }, required:["url"] } }, + { name: "browser_snapshot", description: "Read the active page URL, title, and visible text", inputSchema: { type:"object", properties:{} } }, + { name: "browser_click", description: "Click a visible element by accessible role/name or text", inputSchema: { type:"object", properties:{ text:{type:"string"}, role:{type:"string"} }, required:["text"] } }, + { name: "browser_type", description: "Type into an input or contenteditable element using real keyboard events", inputSchema: { type:"object", properties:{ selector:{type:"string"}, text:{type:"string"}, slowly:{type:"boolean"} }, required:["selector","text"] } }, + { name: "browser_evaluate", description: "Run a small JavaScript expression in the active page and return its JSON-safe result", inputSchema: { type:"object", properties:{ expression:{type:"string"} }, required:["expression"] } }, + { name: "browser_upload", description: "Set a file on a file input", inputSchema: { type:"object", properties:{ selector:{type:"string"}, path:{type:"string"} }, required:["path"] } }, + { name: "browser_screenshot", description: "Save a screenshot of the active page", inputSchema: { type:"object", properties:{ path:{type:"string"} }, required:["path"] } } +]; +async function active() { + if (!browser) browser = await chromium.connectOverCDP(process.env.CDP_URL || "http://127.0.0.1:9222"); + const ctx = browser.contexts()[0]; + page ||= ctx.pages().find(p => /^https?:/.test(p.url())) || await ctx.newPage(); + return page; +} +const result = (text) => ({ content:[{type:"text",text}] }); +async function call(name, a) { + if (name === "browser_tabs") { await active(); const pages=browser.contexts()[0].pages(); const tabs=[]; for (let i=0;i"")}); return result(JSON.stringify(tabs,null,2)); } + const p = await active(); + if (name === "browser_navigate") { await p.goto(a.url,{waitUntil:"domcontentloaded"}); return result(`Navigated to ${p.url()}`); } + if (name === "browser_snapshot") return result(JSON.stringify({url:p.url(),title:await p.title(),text:(await p.locator("body").innerText()).slice(0,20000)},null,2)); + if (name === "browser_click") { const loc=a.role ? p.getByRole(a.role,{name:a.text}).first() : p.getByText(a.text,{exact:true}).first(); await loc.click({timeout:10000}); return result(`Clicked ${a.text}`); } + if (name === "browser_type") { const loc=a.selector ? p.locator(a.selector).first() : p.locator("textarea,input,[contenteditable=true]").first(); await loc.click(); await loc.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await loc.press("Backspace"); await loc.pressSequentially(a.text, {delay:a.slowly ? 35 : 0}); return result("Typed input"); } + if (name === "browser_evaluate") { const value = await p.evaluate((expression) => { const fn = new Function(`return (${expression})`); return fn(); }, a.expression); return result(JSON.stringify(value ?? null)); } + if (name === "browser_upload") { await p.locator(a.selector || "input[type=file]").first().setInputFiles(a.path); return result(`Uploaded ${a.path}`); } + if (name === "browser_screenshot") { await p.screenshot({path:a.path,fullPage:false}); return result(`Saved screenshot to ${a.path}`); } + throw new Error(`Unknown tool ${name}`); +} +const rl=readline.createInterface({input:process.stdin}); +rl.on("line", async line=>{ try { const q=JSON.parse(line); let r; if(q.method==="initialize") r={protocolVersion:"2024-11-05",capabilities:{tools:{}},serverInfo:{name:"buzz-youtube-browser",version:"0.1.0"}}; else if(q.method==="notifications/initialized") return; else if(q.method==="tools/list") r={tools}; else if(q.method==="tools/call") r=await call(q.params.name,q.params.arguments||{}); else r={}; process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:q.id,result:r})+"\n"); } catch(e){ process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:JSON.parse(line).id,error:{code:-32603,message:e.message}})+"\n"); }}); diff --git a/buzz-app.sh b/buzz-app.sh new file mode 100755 index 000000000..bc83767a7 --- /dev/null +++ b/buzz-app.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Buzz on-demand launcher. +# +# Opening the app: brings up the self-host stack (Postgres/Redis/MinIO + friends +# via docker compose), applies migrations/seed if needed, starts the buzz-relay +# release binary, then launches the packaged Buzz desktop app. +# +# Closing the app: stops the relay and tears the Docker stack down (named +# volumes are preserved, so your data/schema survive between sessions). +set -uo pipefail + +REPO="/home/mrmoe28/Desktop/repos/buzz" +cd "$REPO" || { echo "[Buzz] repo not found at $REPO"; exit 1; } + +# Pinned toolchain (just / cargo / node / pnpm) — needed for migrations + seed. +. ./bin/activate-hermit >/dev/null 2>&1 || true +export PATH="$REPO/bin:$PATH" + +# Load .env into this script's own environment so every subprocess we launch +# directly (buzz-relay release binary) sees real credentials, not the +# hardcoded buzz_dev/localhost fallbacks baked into config.rs defaults. +if [[ -r "$REPO/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$REPO/.env" + set +a +fi + +# Installed desktop app binary (resolved at package-install time). +APP_BIN="/usr/bin/buzz-desktop" + +# WebKitGTK on NVIDIA segfaults inside its GPU compositing path (crash traced to +# libwebkit2gtk-4.1 / libjavascriptcoregtk). Disabling DMABUF alone is not enough; +# compositing mode must also be off or the app SIGSEGVs on launch every time. +export WEBKIT_DISABLE_COMPOSITING_MODE=1 +export WEBKIT_DISABLE_DMABUF_RENDERER=1 + +RELAY_PID="" +ACP_PID="" +CHROME_PID="" +teardown() { + echo + echo "[Buzz] Shutting down stack..." + [[ -n "$ACP_PID" ]] && kill "$ACP_PID" 2>/dev/null || true + [[ -n "$CHROME_PID" ]] && kill "$CHROME_PID" 2>/dev/null || true + [[ -n "$RELAY_PID" ]] && kill "$RELAY_PID" 2>/dev/null || true + pkill -f "target/release/buzz-relay" 2>/dev/null || true + docker compose down 2>/dev/null || true # keeps named volumes (data persists) + echo "[Buzz] Stopped." +} +trap teardown EXIT INT TERM + +echo "[Buzz] Bringing up self-host services + migrations..." +if ! just _ensure-migrations; then + echo "[Buzz] Service/migration setup failed — see output above." >&2 + exit 1 +fi + +# Clear any stale relay still holding the relay/health/metrics ports (e.g. a +# leftover from a crashed session) so we always run our own fresh relay and +# never bind against a stale process. Kill by exact PID (pkill -f self-matches). +for port in 3000 8080 9102; do + holder=$(ss -ltnp 2>/dev/null | grep -E ":${port} " | grep -oP 'pid=\K[0-9]+' | head -1) + if [ -n "$holder" ]; then + echo "[Buzz] Clearing stale process $holder on :$port" + kill -9 "$holder" 2>/dev/null || true + fi +done + +echo "[Buzz] Starting relay (ws://localhost:3000)..." +nice -n 5 ./target/release/buzz-relay > "$REPO/relay-runtime.log" 2>&1 & +RELAY_PID=$! + +echo -n "[Buzz] Waiting for relay" +for _ in $(seq 1 40); do + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + echo " FAILED — relay exited early. See relay-runtime.log:" >&2 + tail -20 "$REPO/relay-runtime.log" >&2 + exit 1 + fi + # Ready only when OUR relay pid owns :3000 (not some other process). + if ss -ltnp 2>/dev/null | grep -E ":3000 " | grep -q "pid=${RELAY_PID}"; then + echo " ready"; break + fi + echo -n "."; sleep 1 +done + +echo "[Buzz] Ensuring Chrome CDP bridge is available..." +if ! curl -fsS --max-time 2 http://127.0.0.1:9222/json/version >/dev/null 2>&1; then + CHROME_BIN="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium || true)" + if [[ -n "$CHROME_BIN" ]]; then + "$CHROME_BIN" \ + --user-data-dir="$HOME/.eko-chrome" \ + --remote-debugging-port=9222 \ + --no-first-run --no-default-browser-check \ + >/tmp/buzz-chrome-cdp.log 2>&1 & + CHROME_PID=$! + for _ in $(seq 1 20); do + curl -fsS --max-time 1 http://127.0.0.1:9222/json/version >/dev/null 2>&1 && break + sleep 1 + done + if ! curl -fsS --max-time 2 http://127.0.0.1:9222/json/version >/dev/null 2>&1; then + echo "[Buzz] Chrome CDP failed to start — see /tmp/buzz-chrome-cdp.log" >&2 + CHROME_PID="" + fi + else + echo "[Buzz] Chrome/Chromium not found; browser automation unavailable." >&2 + fi +fi + +echo "[Buzz] Starting Codex ACP agent..." +if [[ -r "$REPO/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$REPO/.env" + set +a +fi +if [[ -n "${BUZZ_PRIVATE_KEY:-}" && -n "${BUZZ_ACP_AGENT_COMMAND:-}" ]]; then + "$REPO/target/release/buzz-acp" > "$REPO/buzz-acp-runtime.log" 2>&1 & + ACP_PID=$! + sleep 1 + if ! kill -0 "$ACP_PID" 2>/dev/null; then + echo "[Buzz] Codex ACP failed to start — see buzz-acp-runtime.log:" >&2 + tail -30 "$REPO/buzz-acp-runtime.log" >&2 + ACP_PID="" + fi +else + echo "[Buzz] ACP skipped: BUZZ_PRIVATE_KEY or BUZZ_ACP_AGENT_COMMAND is missing." +fi + +echo "[Buzz] Launching desktop app..." +if [[ ! -x "$APP_BIN" ]]; then + echo "[Buzz] Packaged app binary not found at: $APP_BIN" >&2 + exit 1 +fi +# BUZZ_PRIVATE_KEY must NOT reach the desktop app. `.env` is sourced above with +# `set -a` for the relay and the ACP agent (which genuinely need that key), but +# the app treats BUZZ_PRIVATE_KEY as a dev/CI identity override that takes +# precedence over the OS keyring (see app_state.rs `identity_from_env`). Leaving +# it exported makes every launch boot as the .env identity instead of the +# owner's, which drops the app into the "enter your private key" re-import +# screen on each restart. +env -u BUZZ_PRIVATE_KEY "$APP_BIN" +# When the app window closes, control returns here and the EXIT trap tears down. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d2498..12e5c4290 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,5 +1,11 @@ You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session. +## Session Model + +You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state. + +When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. + ## Buzz CLI The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON. diff --git a/crates/buzz-conformance/src/lib.rs b/crates/buzz-conformance/src/lib.rs index 3e1cfe13e..b8e3f933d 100644 --- a/crates/buzz-conformance/src/lib.rs +++ b/crates/buzz-conformance/src/lib.rs @@ -315,6 +315,27 @@ pub trait Tracer: Send + Sync { /// Record one trace step. Implementations MAY be no-ops in production /// builds and write to JSONL in tests. fn record(&self, step: TraceStep); + + /// Whether recorded steps are actually observed. + /// + /// Emitters on hot paths MUST consult this before doing work whose + /// *only* consumer is the trace — most importantly extra database + /// reads that project row labels independently of the fetch query + /// (the read-seam's `communities_of_channels` lookup). With a + /// discarding tracer that work is pure overhead. + /// + /// This is the `log.isDebugEnabled()` of the trace seam. It exists to + /// let callers skip *building emit inputs*, never to let them skip an + /// emit they would otherwise have made: when this returns `true` + /// every seam must behave exactly as it did before the gate existed, + /// so the coverage-breach guard stays non-vacuous. + /// + /// Defaults to `true` — a new tracer is assumed to observe steps until + /// it says otherwise. Wrappers that delegate to an inner tracer MUST + /// forward this method rather than inherit the default. + fn enabled(&self) -> bool { + true + } } /// A no-op tracer for production. Zero cost: the build can omit emission @@ -324,4 +345,9 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs. + fn enabled(&self) -> bool { + false + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bb..65ca15672 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + assert_eq!(migrations.len(), 27); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,27 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); + + // Channel-id lookup index (0027): serves the tenant-independent + // `channels` lookups that carry no community_id predicate, which no + // community_id-leading index can satisfy. Covering + partial so the + // planner can go index-only; asserted NOT UNIQUE because `id` alone is + // not unique in this table (the same channel id may exist under more + // than one community), so a unique index would encode a false + // constraint and fail to build on such a database. + assert_eq!(migrations[26].version, 27); + let channel_id_index = migrations[26].sql.as_str(); + assert!(channel_id_index.contains("idx_channels_id_live")); + assert!(channel_id_index.contains("INCLUDE (community_id)")); + assert!(channel_id_index.contains("WHERE deleted_at IS NULL")); + assert!( + !channel_id_index.contains("CREATE UNIQUE INDEX"), + "channels.id is not unique across communities — index must not be UNIQUE", + ); + assert!( + desired_schema.contains("idx_channels_id_live"), + "desired-state schema must carry the channel-id lookup index", + ); } #[test] @@ -1161,7 +1182,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(26)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(27)); } #[tokio::test] diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 323d0aca0..93ebe5de9 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -370,6 +370,16 @@ impl Tracer for CountingTracer { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.inner.record(step); } + + /// Delegate, never inherit the `true` default. This wrapper is + /// transparent: whether emits are observed is a property of the + /// tracer underneath it. Returning `true` over a `NoopTracer` would + /// reintroduce the overhead the gate exists to remove; returning + /// `false` over a real tracer would suppress the emits whose absence + /// the `EmitGuard` reports as a coverage breach. + fn enabled(&self) -> bool { + self.inner.enabled() + } } impl EmitGuard { @@ -455,6 +465,52 @@ mod tests { } } + /// Discarding tracer that reports `enabled() == false`, standing in + /// for the production `NoopTracer`. + #[derive(Debug, Default)] + struct DisabledTracer; + + impl Tracer for DisabledTracer { + fn record(&self, _step: TraceStep) {} + fn enabled(&self) -> bool { + false + } + } + + /// `CountingTracer` must forward `enabled()` to the tracer it wraps + /// rather than inherit the trait's `true` default. Both directions + /// matter, and getting either wrong is silent: + /// + /// - over a disabled tracer, answering `true` would keep the hot-path + /// read-seam `channels` lookup running in production — the overhead + /// the gate exists to remove; + /// - over a live tracer, answering `false` would make gated emitters + /// skip emits during conformance runs, so the `EmitGuard` would + /// report `ImplBug` for seams that are in fact correct (or, worse, + /// mask a real breach behind an expected one). + #[test] + fn counting_tracer_delegates_enabled_to_inner() { + let (_guard, counting) = EmitGuard::arm( + Arc::new(DisabledTracer), + dummy_state(), + "delegates_disabled", + ); + assert!( + !counting.enabled(), + "CountingTracer must report disabled when wrapping a discarding tracer" + ); + + let (_guard, counting) = EmitGuard::arm( + Arc::new(VecTracer::default()), + dummy_state(), + "delegates_live", + ); + assert!( + counting.enabled(), + "CountingTracer must report enabled when wrapping an observing tracer" + ); + } + fn dummy_state() -> AbstractState { AbstractState { resolved_community: CommunityLabel::from_uuid(Uuid::from_u128(0xA)), diff --git a/crates/buzz-relay/src/conformance/tracers.rs b/crates/buzz-relay/src/conformance/tracers.rs index 682c1714e..36c978935 100644 --- a/crates/buzz-relay/src/conformance/tracers.rs +++ b/crates/buzz-relay/src/conformance/tracers.rs @@ -17,6 +17,12 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs — + /// including the read-seam's per-request `channels` lookup. + fn enabled(&self) -> bool { + false + } } /// JSONL-to-file tracer for tests + the CI replay job. Each `record` call diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2aed12cd7..fd7deadf5 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -334,7 +334,12 @@ pub async fn handle_req( // (B) projection strategy and the missing-lookup ImplBug // guard-rail. Skipped silently if `trace_state` is `None` (only // happens on malformed pubkey, a separate failure path). - if let Some(state_snap) = trace_state.as_ref() { + // `tracer.enabled()` short-circuits the whole block on the production + // `NoopTracer`: the `communities_of_channels` lookup below is a + // `channels` read whose only consumer is `record_read_message_rows`, + // and this emit runs once PER FILTER. Gating on `trace_state` alone was + // not enough — that is `Some` for every well-formed request. + if let Some(state_snap) = trace_state.as_ref().filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { @@ -659,7 +664,9 @@ async fn handle_search_req( // level isn't bound to a single channel filter, the // per-row `channel_id` carries the channel identity // honestly. - if let Some(state_snap) = trace_state { + // Same `enabled()` gate as the non-search lane: skip the + // trace-only `channels` lookup when nothing observes the emit. + if let Some(state_snap) = trace_state.filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e561e502c..231691118 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -138,6 +138,7 @@ export default defineConfig({ "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", + "**/needs-restart-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 12e6983ee..2dc0ba0d6 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -12,10 +12,10 @@ use crate::{ RuntimeConfigSurface, SessionConfigCache, }, }, - current_instance_id, is_reserved_env_key, is_well_formed_env_key, known_acp_runtime, - load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, - AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, + current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, + known_acp_runtime, load_managed_agents, load_personas, save_managed_agents, + sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -209,27 +209,6 @@ pub struct BakedEnvEntry { pub masked: bool, } -/// Returns `true` when a baked-env key is safe to display unmasked in the UI. -/// -/// This uses an explicit allowlist of keys that are known safe (non-secret). -/// Any key NOT in this set is masked — default-deny for a security surface. -/// -/// Allowlist (case-insensitive): -/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection -/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max) -/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults -fn is_safe_to_reveal(key: &str) -> bool { - const SAFE_KEYS: &[&str] = &[ - "BUZZ_AGENT_PROVIDER", - "BUZZ_AGENT_MODEL", - "BUZZ_AGENT_THINKING_EFFORT", - "DATABRICKS_HOST", - "DATABRICKS_MODEL", - ]; - let upper = key.to_ascii_uppercase(); - SAFE_KEYS.iter().any(|safe| upper == *safe) -} - /// Expose the baked build env to the frontend with values shown, but any /// key not in the safe-to-reveal allowlist has its value replaced by `••••••`. /// diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230..e06f17621 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -76,7 +76,7 @@ pub(super) fn build_launch_block( policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); } if let Some(value) = - crate::managed_agents::spawn_hash::effective_team_instructions(record, teams) + crate::managed_agents::spawn_snapshot::effective_team_instructions(record, teams) { policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } diff --git a/desktop/src-tauri/src/huddle/agent_voice.rs b/desktop/src-tauri/src/huddle/agent_voice.rs index 5232287d7..6911cb02c 100644 --- a/desktop/src-tauri/src/huddle/agent_voice.rs +++ b/desktop/src-tauri/src/huddle/agent_voice.rs @@ -62,6 +62,32 @@ fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> (hash as usize) % len } +/// Pick a stable voice for an agent speaking on the ordinary timeline. +/// +/// The huddle assignment in [`sync_agent_voice_assignments`] is session state — +/// it needs a live roster and resets with the huddle. Timeline speech has +/// neither, so the choice is derived purely from the pubkey: the same agent +/// always gets the same voice, across restarts, without anything to persist. +/// Agents beyond the number of installed voices necessarily share one. +pub(crate) fn timeline_agent_voice_key( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Option { + let catalog = catalog(app, state).ok()?; + let mut keys: Vec<_> = catalog + .voices + .iter() + .map(|voice| voice.key.clone()) + .collect(); + // Registry order depends on import order; sort so the mapping is stable. + keys.sort(); + if keys.is_empty() { + return None; + } + Some(keys[stable_voice_index(agent_pubkey, 0, keys.len())].clone()) +} + pub(crate) fn sync_agent_voice_assignments( huddle: &mut HuddleState, agent_pubkeys: &[String], diff --git a/desktop/src-tauri/src/huddle/chat_tts.rs b/desktop/src-tauri/src/huddle/chat_tts.rs new file mode 100644 index 000000000..7d8958a2f --- /dev/null +++ b/desktop/src-tauri/src/huddle/chat_tts.rs @@ -0,0 +1,145 @@ +//! Speaks agent chat messages aloud outside of a huddle session. +//! +//! Huddle TTS is tied to a live call; this path serves the timeline instead — +//! an agent posts a normal channel message and the desktop reads it out. The +//! pipeline is created lazily on the first spoken message and then kept warm, +//! because building one loads the Pocket model (seconds) while reusing one +//! costs nothing. A voice-preference change re-selects the reference style on +//! the existing pipeline rather than rebuilding it. + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, +}; + +use tauri::AppHandle; + +use super::{models, tts::TtsPipeline, tts_settings::pocket_voice_reference}; + +struct ChatTts { + pipeline: TtsPipeline, + /// Reference-voice name the live pipeline was built or last switched to. + voice: String, + /// `true` while audio is actually playing. Shared with the pipeline worker. + active: Arc, + /// Barge-in flag: set to silence the player and drop everything queued. + /// The worker consumes it, so it is only ever set while `active` is true — + /// a stale flag set during silence would swallow the next message. + cancel: Arc, +} + +static CHAT_TTS: OnceLock>> = OnceLock::new(); + +fn slot() -> &'static Mutex> { + CHAT_TTS.get_or_init(|| Mutex::new(None)) +} + +/// Synthesize and play `text` with the configured Pocket voice. +/// +/// Returns `Ok(())` without speaking when the model files are still +/// downloading, so a caller firing on every agent message never surfaces a +/// transient startup error as a failure. +/// +/// `voice_reference` speaks this one message in a specific voice without +/// changing the pipeline's selected voice, so each agent can sound different +/// while the warmed engine is shared. +pub fn speak( + app: &AppHandle, + voice_preferences: &[String], + text: String, + voice_reference: Option, +) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + if !models::is_tts_ready() { + return Ok(()); + } + let model_dir = models::tts_model_dir().ok_or("Pocket voice files are unavailable")?; + let voice = pocket_voice_reference(app, voice_preferences)?; + + let mut guard = slot() + .lock() + .map_err(|_| "chat text-to-speech lock poisoned".to_string())?; + + // A finished worker means the pipeline died (init failure or crash); drop + // it so the next speak rebuilds instead of silently queueing into nothing. + if guard.as_ref().is_some_and(|c| c.pipeline.is_finished()) { + *guard = None; + } + + match guard.as_mut() { + Some(chat) => { + if chat.voice != voice { + chat.pipeline.select_voice(&voice); + chat.voice = voice; + } + } + None => { + let active = Arc::new(AtomicBool::new(false)); + let cancel = Arc::new(AtomicBool::new(false)); + let pipeline = TtsPipeline::new_with_voice( + model_dir, + Arc::clone(&active), + Arc::clone(&cancel), + &voice, + None, + None, + )?; + *guard = Some(ChatTts { + pipeline, + voice, + active, + cancel, + }); + } + } + + guard + .as_ref() + .expect("chat tts installed above") + .pipeline + .speak_with_voice(text, voice_reference) +} + +/// `true` while the timeline pipeline is playing audio. +pub fn is_speaking() -> bool { + slot() + .lock() + .ok() + .and_then(|guard| { + guard + .as_ref() + .map(|chat| chat.active.load(Ordering::Acquire)) + }) + .unwrap_or(false) +} + +/// Stop the current utterance and drop everything still queued. +/// +/// No-op while nothing is playing: the worker only consumes the cancel flag on +/// its way through the queue, so setting it during silence would drop the next +/// message instead of the current one. Mirrors the huddle push-to-talk gate. +pub fn stop() -> bool { + let Ok(guard) = slot().lock() else { + return false; + }; + let Some(chat) = guard.as_ref() else { + return false; + }; + if !chat.active.load(Ordering::Acquire) { + return false; + } + chat.cancel.store(true, Ordering::Release); + true +} + +/// Drop the warm pipeline, releasing the model and audio device. +pub fn shutdown() { + let Ok(mut guard) = slot().lock() else { + return; + }; + if let Some(chat) = guard.take() { + chat.pipeline.shutdown(); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 99337400c..bc46348b0 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -27,6 +27,7 @@ mod agent_tts_routing; pub mod agent_voice; pub mod agents; pub mod audio_output; +pub mod chat_tts; pub mod jitter; pub mod models; pub mod pipeline; @@ -39,6 +40,7 @@ pub mod state; pub mod stt; pub mod transcription; pub mod tts; +pub mod tts_controls; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 1901bb3d2..ea6cfe072 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -41,7 +41,7 @@ use std::{ sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, - Arc, Mutex, MutexGuard, PoisonError, + Arc, Mutex, }, thread, time::{Duration, Instant}, @@ -234,12 +234,26 @@ impl TtsPipeline { /// Non-blocking. Returns `Err` if the queue is full (bounded at /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. pub fn speak(&self, text: String) -> Result<(), String> { + self.speak_with_voice(text, None) + } + + /// Queue `text` with an explicit reference voice for this message only. + /// + /// Unlike [`Self::select_voice`], this leaves the pipeline's selected voice + /// alone and cancels nothing already queued — the worker swaps reference + /// styles between items from its cache, so consecutive messages from + /// different agents each keep their own voice without losing order. + pub fn speak_with_voice( + &self, + text: String, + voice_reference: Option, + ) -> Result<(), String> { self.text_tx .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), route_id: 0, speaker_pubkey: None, - voice_reference: None, + voice_reference, text, }) .map_err(|e| { @@ -906,88 +920,6 @@ fn tts_worker( // ── Helpers ─────────────────────────────────────────────────────────────────── -/// Check for cancel or shutdown. Returns `true` if the caller should break/continue. -/// On cancel: drains the text queue and clears the cancel flag. -/// -/// `player` pairs the Player with the `player_ops` mutex shared with the -/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so -/// it is serialized with the monitor's stale-branch re-check (see the monitor -/// block in `tts_worker`). -fn handle_cancel_or_shutdown( - cancel_signals: CancelSignals<'_>, - shutdown: &AtomicBool, - tts_active: &AtomicBool, - text_state: CancelTextState<'_>, - voice_change_ack: &VoiceChangeAck, - active_route_id: Option, - player: Option<(&rodio::Player, &Mutex<()>)>, -) -> bool { - let (cancel, voice_cancel) = cancel_signals; - let (text_rx, deferred_text, current_text) = text_state; - if shutdown.load(Ordering::Acquire) { - eprintln!( - "buzz-desktop: tts stage=cancellation reason=shutdown route_id={}", - active_route_id.unwrap_or(0) - ); - if let Some((p, ops)) = player { - let _ops = lock_player_ops(ops); - p.clear(); - } - tts_active.store(false, Ordering::Release); - return true; - } - if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { - // Serialize with begin_voice_change so the generation boundary and - // cancel consumption are observed as one transition. - let pending_voice_change = voice_change_ack - .lock() - .unwrap_or_else(|error| error.into_inner()); - // Consume at the serialization point. A later barge-in remains true - // for the next pass instead of being overwritten after queue cleanup. - let barge_in = cancel.swap(false, Ordering::AcqRel); - voice_cancel.store(false, Ordering::Release); - eprintln!( - "buzz-desktop: tts stage=cancellation reason={} route_id={}", - if barge_in { "barge_in" } else { "voice_switch" }, - active_route_id.unwrap_or(0) - ); - let preserve_generation = (!barge_in) - .then(|| { - pending_voice_change - .as_ref() - .map(|pending| pending.generation) - }) - .flatten(); - retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation); - if let Some((p, ops)) = player { - let _ops = lock_player_ops(ops); - // `Player::clear()` removes queued sources AND pauses the player - // (rodio 0.22 `clear()` ends with `self.pause()`). With one - // persistent Player for the worker's lifetime, the un-pause is - // mandatory: without `play()`, every append after a barge-in - // would queue silently forever. - p.clear(); - p.play(); - // Consume the flag under the lock: once released with - // `cancel == false`, the monitor's stale branch no-ops instead - // of clearing the fresh post-cancel utterance. - } - tts_active.store(false, Ordering::Release); - return true; - } - false -} - -/// Acquire the `player_ops` lock, recovering from poison. -/// -/// The data under the mutex is `()` — it only serializes Player mutations — -/// so a panicked holder leaves nothing inconsistent to observe and recovery -/// is always safe. Without this, a worker panic would wedge the monitor (or -/// vice versa) on `unwrap()`. -fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { - ops.lock().unwrap_or_else(PoisonError::into_inner) -} - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/huddle/tts_controls.rs b/desktop/src-tauri/src/huddle/tts_controls.rs new file mode 100644 index 000000000..a266e9db7 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_controls.rs @@ -0,0 +1,52 @@ +//! Manual controls for agent speech already in flight — stop playback and +//! query whether an agent is currently speaking, in a huddle or on the +//! timeline — plus resolving which voice a given speaker should use. + +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; + +/// Per-agent voice reference for `speaker_pubkey`, falling back to `None` +/// (the caller's configured default voice) if none resolves. +pub(crate) fn resolve_speaker_voice_reference( + app: &AppHandle, + state: &State<'_, AppState>, + speaker_pubkey: Option<&str>, +) -> Option { + speaker_pubkey.and_then(|pubkey| { + super::agent_voice::timeline_agent_voice_key(app, state, pubkey) + .and_then(|key| super::tts_settings::pocket_voice_reference(app, &[key]).ok()) + }) +} + +/// Stop agent speech that is playing right now, in a huddle or on the timeline. +/// +/// Leaves the agent-speech setting alone — the next message is spoken as usual. +/// This is the manual equivalent of the huddle barge-in that push-to-talk and +/// remote participant speech already trigger. +#[tauri::command] +pub fn stop_agent_speech(state: State<'_, AppState>) -> Result<(), String> { + super::chat_tts::stop(); + if let Ok(huddle) = state.huddle() { + // Only while audio is actually playing: the worker consumes the flag on + // its way through the queue, so setting it during silence would drop + // the next message instead of the current one. + if huddle.tts_active.load(std::sync::atomic::Ordering::Acquire) { + huddle + .tts_cancel + .store(true, std::sync::atomic::Ordering::Release); + } + } + Ok(()) +} + +/// `true` while an agent is speaking, in a huddle or on the timeline. +#[tauri::command] +pub fn is_agent_speaking(state: State<'_, AppState>) -> bool { + if super::chat_tts::is_speaking() { + return true; + } + state + .huddle() + .is_ok_and(|huddle| huddle.tts_active.load(std::sync::atomic::Ordering::Acquire)) +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 64fd6d8a9..eb8c602ba 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -643,6 +643,33 @@ pub async fn preview_pocket_voice( .map_err(|error| format!("Voice preview task failed: {error}"))? } +/// Read an agent's ordinary channel message aloud with the configured voice. +/// +/// Distinct from `huddle::speak_agent_message`, which only runs inside a live +/// huddle and routes through that session's pipeline. This path serves the +/// normal timeline, so it keeps its own warm pipeline (see `chat_tts`) and +/// works with no huddle active. No-ops when the agent-speech setting is off. +#[tauri::command] +pub async fn speak_chat_message( + text: String, + speaker_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let settings = current_settings(&state)?; + if !settings.agent_text_to_speech { + return Ok(()); + } + let speaker = speaker_pubkey.as_deref(); + let voice_reference = + super::tts_controls::resolve_speaker_voice_reference(&app, &state, speaker); + tokio::task::spawn_blocking(move || { + super::chat_tts::speak(&app, &settings.voice_preferences, text, voice_reference) + }) + .await + .map_err(|error| format!("Agent speech task failed: {error}"))? +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TtsVoiceMutation { diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 3a6555375..b9f64b73f 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -4,7 +4,7 @@ use std::{ sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, - Arc, Mutex, + Arc, Mutex, MutexGuard, PoisonError, }, }; @@ -258,3 +258,85 @@ pub(super) fn retain_cancelled_text( fn log_cancelled_route(route_id: u64, reason: &str) { eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}"); } + +/// Check for cancel or shutdown. Returns `true` if the caller should break/continue. +/// On cancel: drains the text queue and clears the cancel flag. +/// +/// `player` pairs the Player with the `player_ops` mutex shared with the +/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so +/// it is serialized with the monitor's stale-branch re-check (see the monitor +/// block in `tts_worker`). +pub(super) fn handle_cancel_or_shutdown( + cancel_signals: CancelSignals<'_>, + shutdown: &AtomicBool, + tts_active: &AtomicBool, + text_state: CancelTextState<'_>, + voice_change_ack: &VoiceChangeAck, + active_route_id: Option, + player: Option<(&rodio::Player, &Mutex<()>)>, +) -> bool { + let (cancel, voice_cancel) = cancel_signals; + let (text_rx, deferred_text, current_text) = text_state; + if shutdown.load(Ordering::Acquire) { + eprintln!( + "buzz-desktop: tts stage=cancellation reason=shutdown route_id={}", + active_route_id.unwrap_or(0) + ); + if let Some((p, ops)) = player { + let _ops = lock_player_ops(ops); + p.clear(); + } + tts_active.store(false, Ordering::Release); + return true; + } + if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { + // Serialize with begin_voice_change so the generation boundary and + // cancel consumption are observed as one transition. + let pending_voice_change = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + // Consume at the serialization point. A later barge-in remains true + // for the next pass instead of being overwritten after queue cleanup. + let barge_in = cancel.swap(false, Ordering::AcqRel); + voice_cancel.store(false, Ordering::Release); + eprintln!( + "buzz-desktop: tts stage=cancellation reason={} route_id={}", + if barge_in { "barge_in" } else { "voice_switch" }, + active_route_id.unwrap_or(0) + ); + let preserve_generation = (!barge_in) + .then(|| { + pending_voice_change + .as_ref() + .map(|pending| pending.generation) + }) + .flatten(); + retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation); + if let Some((p, ops)) = player { + let _ops = lock_player_ops(ops); + // `Player::clear()` removes queued sources AND pauses the player + // (rodio 0.22 `clear()` ends with `self.pause()`). With one + // persistent Player for the worker's lifetime, the un-pause is + // mandatory: without `play()`, every append after a barge-in + // would queue silently forever. + p.clear(); + p.play(); + // Consume the flag under the lock: once released with + // `cancel == false`, the monitor's stale branch no-ops instead + // of clearing the fresh post-cancel utterance. + } + tts_active.store(false, Ordering::Release); + return true; + } + false +} + +/// Acquire the `player_ops` lock, recovering from poison. +/// +/// The data under the mutex is `()` — it only serializes Player mutations — +/// so a panicked holder leaves nothing inconsistent to observe and recovery +/// is always safe. Without this, a worker panic would wedge the monitor (or +/// vice versa) on `unwrap()`. +pub(super) fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { + ops.lock().unwrap_or_else(PoisonError::into_inner) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d59936946..c180b7f75 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -845,6 +845,9 @@ pub fn run() { huddle::tts_settings::list_voice_registry, huddle::tts_settings::set_pocket_voice, huddle::tts_settings::preview_pocket_voice, + huddle::tts_settings::speak_chat_message, + huddle::tts_controls::stop_agent_speech, + huddle::tts_controls::is_agent_speaking, huddle::tts_settings::import_pocket_voice, huddle::tts_settings::delete_pocket_voice, huddle::agent_voice::ensure_huddle_agent_voice_settings, diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index e6bc09496..ba0448bea 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -268,7 +268,7 @@ pub(crate) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> { /// Thread-safe registry of non-builtin (preset + custom) harness definitions, /// populated on every `discover_acp_runtimes_from` call and queried at spawn time. -fn loaded_harness_registry() -> &'static RwLock>> { +pub(super) fn loaded_harness_registry() -> &'static RwLock>> { use std::sync::OnceLock; static REGISTRY: OnceLock>>> = OnceLock::new(); REGISTRY.get_or_init(|| RwLock::new(Vec::new())) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2cccccb95..fafcb2589 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -13,8 +13,11 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +pub(crate) use presets::{ + canonical_harness_command, command_for_runtime_id, preset_harness_definitions, + preset_harness_ids, +}; use presets::{preset_catalog_entry, PRESET_HARNESSES}; -pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -233,7 +236,7 @@ fn executable_basename(command: &str) -> String { } } -fn normalize_command_identity(command: &str) -> String { +pub(crate) fn normalize_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); let lower = basename @@ -295,9 +298,10 @@ pub fn default_agent_command() -> String { /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the record's own `runtime` id mapped to its primary command — -/// records materialize their runtime at create/migration time; -/// checks both static builtins AND the loaded preset/custom registry; +/// 2. the record's own `runtime` id mapped to its primary command via the +/// authoritative three-tier lookup (static builtins → static preset list +/// → loaded registry) — preset harnesses (e.g. openclaw) resolve +/// correctly even with a cold registry; /// 3. legacy fallback: the linked persona's `runtime` (records created /// before the unified model carry `persona_id` but no `runtime`); /// 4. `default_agent_command()`. @@ -315,15 +319,11 @@ pub fn record_agent_command( } if let Some(id) = record.runtime.as_deref() { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Fall back to loaded registry for preset/custom harnesses. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + // Using the shared resolver ensures preset harnesses (e.g. openclaw) + // resolve correctly even without a warm registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -336,8 +336,9 @@ pub fn record_agent_command( /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the linked persona's `runtime` id mapped to its primary command -/// (checks builtins then loaded preset/custom registry); +/// 2. the linked persona's `runtime` id mapped to its primary command via +/// the authoritative three-tier lookup (static builtins → static preset +/// list → loaded registry); /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, @@ -356,15 +357,9 @@ pub fn effective_agent_command( .and_then(|persona| persona.runtime.as_deref()); if let Some(id) = runtime_id { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Check loaded preset/custom registry. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -423,12 +418,8 @@ pub fn try_record_agent_command( // Record-level runtime id: if set but unresolvable → typed error. if let Some(id) = record.runtime.as_deref() { - if let Some(cmd) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { - return Ok(cmd.to_string()); - } - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -437,15 +428,8 @@ pub fn try_record_agent_command( if let Some(persona_id) = record.persona_id.as_deref() { if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { if let Some(id) = persona.runtime.as_deref() { - if let Some(cmd) = - known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return Ok(cmd.to_string()); - } - if let Some(def) = - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index bcc428800..b2d8a14ef 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -202,6 +202,76 @@ pub(crate) fn preset_harness_ids() -> &'static [&'static str] { .as_slice() } +/// Return the primary command for a preset harness by id, or `None` if the id +/// is not a known preset. +/// +/// Returns a `&'static str` so callers can use it without allocation. +pub(super) fn preset_command_for_id(id: &str) -> Option<&'static str> { + PRESET_HARNESSES + .iter() + .find(|p| p.id == id) + .map(|p| p.command) +} + +/// Return the primary harness command for a given runtime id, or `None`. +/// +/// Checks static builtins, then the static preset list (always available, +/// no registry warm-up required — covers openclaw, devin, cursor, etc.), +/// then the loaded preset/custom registry. +pub(crate) fn command_for_runtime_id(id: &str) -> Option { + super::known_acp_runtime_exact(id) + .and_then(|r| r.commands.first().copied()) + .map(str::to_string) + .or_else(|| preset_command_for_id(id).map(str::to_string)) + .or_else(|| { + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + .map(|d| d.command.clone()) + }) +} + +/// Resolve a harness to its canonical command accepting either a runtime id or +/// a command string (including path prefixes and aliases). +/// +/// This is the pin-classification resolver for `apply_persona_snapshot`: the +/// create-time override in `record.agent_command_override` can hold any of the +/// forms a user or the harness selector might have stored — bare command +/// ("goose"), alias ("claude-code-acp"), path ("/usr/local/bin/goose"), or the +/// runtime id directly ("claude"). All three tiers are searched: +/// +/// 1. **Builtins** — `known_acp_runtime(input)` matches by id, command, or +/// alias in `KNOWN_ACP_RUNTIMES`; returns its first primary command. +/// 2. **Static presets** — searched by id or by normalised command. +/// 3. **Loaded registry** — searched by id or by normalised command. +/// +/// Returns `None` for inputs that do not resolve to any known harness; those +/// pins are treated as custom/unknown and always kept. +pub(crate) fn canonical_harness_command(input: &str) -> Option { + let normalized = super::normalize_command_identity(input); + + // Tier 1: builtins — matched by id, command, or alias. + if let Some(rt) = super::known_acp_runtime(&normalized) { + if let Some(cmd) = rt.commands.first() { + return Some(cmd.to_string()); + } + } + + // Tier 2: static presets — matched by id or by normalized command. + if let Some(p) = PRESET_HARNESSES + .iter() + .find(|p| p.id == normalized || super::normalize_command_identity(p.command) == normalized) + { + return Some(p.command.to_string()); + } + + // Tier 3: loaded registry — matched by id or by normalized command. + let reg = crate::managed_agents::custom_harnesses::loaded_harness_registry() + .read() + .unwrap_or_else(|e| e.into_inner()); + reg.iter() + .find(|d| d.id == normalized || super::normalize_command_identity(&d.command) == normalized) + .map(|d| d.command.clone()) +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7..07705ee99 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -224,6 +224,28 @@ pub fn validate_user_env_keys(env_vars: &BTreeMap) -> Result<(), Ok(()) } +/// Returns `true` when `key` is safe to show verbatim — not a credential. +/// +/// Default-deny: every key NOT in this explicit allowlist is masked. Callers +/// that display env values (baked-env UI, spawn-diff tooltip) share this +/// single authority — no second list. +/// +/// Allowlist (case-insensitive): +/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection +/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max) +/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults +pub(crate) fn is_safe_to_reveal(key: &str) -> bool { + const SAFE_KEYS: &[&str] = &[ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "BUZZ_AGENT_THINKING_EFFORT", + "DATABRICKS_HOST", + "DATABRICKS_MODEL", + ]; + let upper = key.to_ascii_uppercase(); + SAFE_KEYS.iter().any(|safe| upper == *safe) +} + /// Per-value byte cap for env values. 32 KiB is generous for credentials, /// JWT-ish tokens, certs etc., but small enough that a malformed IPC /// caller can't blow up the persona/agent JSON file. Tune up if real diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f2..a848b6f02 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -31,7 +31,7 @@ mod runtime; mod runtime_commands; mod runtime_types; pub(crate) mod snapshot_avatar; -pub(crate) mod spawn_hash; +pub(crate) mod spawn_snapshot; pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6afc18a50..de396f45c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -450,12 +450,12 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { /// This is the single apply used by every snapshot-apply site: the spawn /// re-pin (`start_local_agent_with_preflight`), the launch backfill and /// restore re-snapshot (`restore.rs`), and the prospective re-snapshot inside -/// `spawn_config_hash` — so a future `PersonaSnapshot` field addition -/// propagates to all of them at once. +/// `prospective_spawn_config_snapshot` — so a future `PersonaSnapshot` field +/// addition propagates to all of them at once. /// /// Deliberately does NOT touch `updated_at`: persistence stamps are the -/// caller's concern, and `spawn_config_hash` (which applies this to a clone) -/// must stay pure. +/// caller's concern, and the prospective snapshot (which applies this to a +/// clone) must stay pure. pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { let snapshot = persona_snapshot(persona); if let Some(prompt) = snapshot.system_prompt { @@ -464,23 +464,42 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; - // Drop a stale create-time harness pin when the definition names a - // different known runtime; custom commands stay pinned. - if let Some(def_runtime) = persona + // Drop a stale create-time harness pin when the definition switches to a + // different known runtime (builtin, static preset, or loaded custom). A pin + // that names an unknown/custom command is always kept. + // + // Both sides are resolved through the canonical harness-identity resolver + // (`canonical_harness_command`) which accepts either a runtime id OR a + // command string — covering aliases (e.g. "claude-code-acp"), path prefixes + // ("/usr/local/bin/goose"), and harnesses whose id ≠ command. The persona + // runtime side is resolved via `command_for_runtime_id` (id-only input is + // sufficient there since persona.runtime is always an authoritative id). + // + // Comparison is on canonical primary commands so "goose", "/usr/local/bin/goose", + // and runtime id "goose" all represent the same harness; the stale pin is + // dropped only when the canonical commands differ. + if let Some(new_cmd) = persona .runtime .as_deref() .map(str::trim) .filter(|r| !r.is_empty()) - .and_then(crate::managed_agents::known_acp_runtime_exact) + .and_then(super::command_for_runtime_id) { - if let Some(pin_runtime) = record + if let Some(pin) = record .agent_command_override .as_deref() - .and_then(crate::managed_agents::known_acp_runtime) + .map(str::trim) + .filter(|v| !v.is_empty()) { - if !std::ptr::eq(pin_runtime, def_runtime) { - record.agent_command_override = None; + // Resolve the pin via the canonical resolver (accepts id OR command). + if let Some(pin_cmd) = super::canonical_harness_command(pin) { + if pin_cmd != new_cmd { + // Known harness switched to a different known harness — drop stale pin. + record.agent_command_override = None; + } + // Same harness: keep the pin (e.g. explicit path override for same runtime). } + // Custom/unknown pin: always keep. } } // env_vars stay overrides-only. Self-heal records written before the env @@ -498,8 +517,9 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe /// paths re-pin it to its linked persona, without mutating `record` itself. /// /// Every decision made ahead of the real re-pin — the relay-mesh preflight in -/// `start_local_agent_with_preflight`, the restart-badge hash in -/// `spawn_config_hash` — needs to reason about spawn-time state, not +/// `start_local_agent_with_preflight`, the restart-badge snapshot in +/// `prospective_spawn_config_snapshot` — needs to reason about spawn-time +/// state, not /// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider` /// to/from relay-mesh) between saves is reflected in the decision instead of /// the stale value the real [`apply_persona_snapshot`] is about to overwrite @@ -507,7 +527,7 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe /// so the spawn-time stamp and later recomputes agree when nothing changed. /// /// Orphaned records (persona deleted) pass through unchanged: the caller's -/// own orphan handling — refusing to spawn, hashing as `(None, None, None)` +/// own orphan handling — refusing to spawn, snapshotting as `(None, None, None)` /// — runs on the real record downstream, not on this preview. pub fn preview_prospective_persona_snapshot( record: &ManagedAgentRecord, @@ -522,4 +542,6 @@ pub fn preview_prospective_persona_snapshot( preview } #[cfg(test)] +mod stale_pin_tests; +#[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs new file mode 100644 index 000000000..c34ab1739 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -0,0 +1,101 @@ +//! Stale-pin drop tests for `apply_persona_snapshot`. +//! +//! Covers the `canonical_harness_command` resolver used to classify a +//! create-time `agent_command_override` before deciding whether it should be +//! dropped when the persona switches to a different harness. + +use super::tests::{sample_persona, sample_record}; +use crate::managed_agents::persona_events::apply_persona_snapshot; +use crate::managed_agents::types::AgentDefinition; + +// ── Stale-pin drop: OpenClaw↔Goose (preset↔builtin) ───────────────────────── + +/// Persona→OpenClaw: stale Goose override dropped. +/// Regression for the original preset stale-pin fix. +#[test] +fn apply_persona_snapshot_goose_to_openclaw_drops_stale_goose_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to openclaw" + ); +} + +/// Persona→Goose: stale OpenClaw override dropped. +#[test] +fn apply_persona_snapshot_openclaw_to_goose_drops_stale_openclaw_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("openclaw".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale openclaw pin must be dropped when persona switches to goose" + ); +} + +// ── Stale-pin drop: alias pin (command ≠ id) ───────────────────────────────── + +/// Persona→OpenClaw; record has a stale `claude-code-acp` alias pin (id="claude", +/// command="claude-agent-acp"). The canonical resolver must recognise the alias +/// as the Claude harness and drop it when the persona switches to a different +/// harness (OpenClaw). +/// +/// This is the correctness case that motivated the `canonical_harness_command` +/// resolver: the old pointer-comparison code treated the alias as a +/// custom/unknown pin and kept it — the agent kept running Claude instead of +/// OpenClaw. +#[test] +fn apply_persona_snapshot_claude_alias_pin_to_openclaw_drops_stale_alias() { + let mut record = sample_record(); + // "claude-code-acp" is an alias of the Claude runtime (id="claude"). + record.agent_command_override = Some("claude-code-acp".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale claude-code-acp alias pin must be dropped when persona switches to openclaw" + ); +} + +// ── Stale-pin keep: same harness, path/alias override ─────────────────────── + +/// Same-harness case: record has an explicit path override pointing at the same +/// harness as the new persona runtime. The pin must NOT be dropped — it is a +/// deliberate per-instance configuration (e.g. a specific goose binary path). +#[test] +fn apply_persona_snapshot_same_harness_path_pin_is_kept() { + let mut record = sample_record(); + // Explicit path override for goose — same harness as the persona runtime. + record.agent_command_override = Some("/usr/local/bin/goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override.as_deref(), + Some("/usr/local/bin/goose"), + "same-harness path override must NOT be dropped" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a8..0580b12ce 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -3,7 +3,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// A linked instance record with no persona-derived fields set yet — the /// state right after creation, before any snapshot apply. -fn sample_record() -> ManagedAgentRecord { +pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), name: "agent".into(), @@ -139,7 +139,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); } -fn sample_persona() -> AgentDefinition { +pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { id: "test-persona".to_string(), display_name: "Test Persona".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 8dddf9f71..479d6ec91 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -133,7 +133,7 @@ pub fn taskkill_tree(pid: u32) -> Result<(), String> { pub fn finish_spawn( child: std::process::Child, log_path: std::path::PathBuf, - spawn_config_hash: u64, + spawn_config: super::spawn_snapshot::SpawnConfigSnapshot, setup_mode: bool, adapter_availability: Option, start_nonce: String, @@ -149,7 +149,7 @@ pub fn finish_spawn( super::ManagedAgentProcess { child, log_path, - spawn_config_hash, + spawn_config, setup_mode, adapter_availability, start_nonce, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 26902ae8d..c072448ff 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -82,7 +82,7 @@ pub(crate) struct EffectiveAgentEnv { // // A single owned type that fully describes what a spawn would run. Produced // by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_config_hash, build_managed_agent_summary, get_agent_models, and +// spawn_snapshot, build_managed_agent_summary, get_agent_models, and // agent_readiness — so the harness-definition lookup and arg/env resolution // happen exactly once, in one place. diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 191062015..25dadbeec 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -18,7 +18,9 @@ use tauri::Manager; /// restore would kill reconcile's lazy child by its receipt and replace it with /// an eager one, flipping the pair's laziness on a startup race. enum SpawnOutcome { - Spawned(super::ManagedAgentRuntimeKey, ManagedAgentProcess), + /// Boxed: the spawned process carries its full spawn-config snapshot, so an + /// inline variant would make every `Skipped`/`Failed` outcome pay for it. + Spawned(super::ManagedAgentRuntimeKey, Box), Skipped, Failed(String), } @@ -338,7 +340,9 @@ pub async fn restore_managed_agents_on_launch( owner_hex_ref, ) }) { - Ok(process) => SpawnOutcome::Spawned(key, process), + Ok(process) => { + SpawnOutcome::Spawned(key, Box::new(process)) + } Err(error) => SpawnOutcome::Failed(error), } } @@ -400,7 +404,7 @@ pub async fn restore_managed_agents_on_launch( record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert(key, super::ManagedAgentPairRuntime::starting(process)); + runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process)); successfully_spawned.push(pubkey); } SpawnOutcome::Failed(error) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3173126b9..f29a0d3d2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -70,13 +70,12 @@ use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; /// Classify an agent's persona against the live catalog for the Agents-menu -/// drift indicator. Returns `(out_of_date, orphaned)`. -/// -/// Drift basis is the RECORD's `persona_source_version`, never the engram: +/// drift indicator. Returns `(out_of_date, orphaned)`. Drift basis is the +/// RECORD's `persona_source_version`, never the engram: /// - persona_id set + persona present: out_of_date when the snapshot hash /// differs from the persona's current content hash. -/// - persona_id set + persona gone: orphaned (no current hash to respawn into, -/// so never out_of_date — we must not tell the user to respawn into nothing). +/// - persona_id set + persona gone: orphaned (no current hash to respawn +/// into, so never out_of_date — don't tell the user to respawn nothing). /// - no persona_id: neither — a hand-built agent has no persona to drift from. fn persona_drift_state( record: &ManagedAgentRecord, @@ -98,11 +97,10 @@ fn persona_drift_state( (out_of_date, false) } -/// Resolve the runtime-pair key this record maps to for the active -/// workspace: always the active workspace relay (the legacy per-record relay -/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for -/// records that cannot form a valid pair key yet (e.g. key-less agents that -/// mint keys on first start). +/// Resolve the runtime-pair key this record maps to for the active workspace: +/// always the active workspace relay (legacy per-record relay pin ignored — +/// see `effective_agent_relay_url`). `None` for records that cannot form a +/// valid pair key yet (e.g. key-less agents that mint keys on first start). pub(crate) fn workspace_pair_key( app: &AppHandle, record: &ManagedAgentRecord, @@ -117,8 +115,8 @@ pub(crate) fn workspace_pair_key( } /// Pure core of [`workspace_pair_key`]: workspace-relay resolution (legacy -/// record pins ignored) plus canonical key construction, kept `AppHandle`-free -/// so summary/stop scoping semantics are unit-testable. +/// record pins ignored) plus canonical key construction, kept +/// `AppHandle`-free so summary/stop scoping semantics are unit-testable. pub(crate) fn resolve_workspace_pair_key( pubkey: &str, record_relay_url: &str, @@ -225,49 +223,50 @@ pub fn build_managed_agent_summary( } }; - // Restart badge: the running process stamped its effective spawn config - // at launch; recompute from current disk state and flag drift. Only the - // tracked live pair for THIS workspace can drift — stopped agents spawn - // fresh, adopted (runtime_pid-only) processes have no stamped hash to - // compare, and pairs running for other communities are judged in their - // own community (hashing them against this workspace's relay would flag - // a spurious restart on every community switch). + // Restart badge: the running process stamped the effective spawn config + // it was launched with; recompute a prospective one from current disk + // state and report every differing field. Only the tracked live pair for + // THIS workspace can drift — stopped agents spawn fresh, adopted + // (runtime_pid-only) processes have no stamp to compare, and pairs running + // for other communities are judged in their own community (comparing them + // against this workspace's relay would flag a spurious restart on every + // community switch). // - // Additionally, for runtimes with an adapter version gate (codex only), - // check whether the cached adapter availability has drifted from the value - // stamped at spawn. This catches out-of-band adapter changes (manual - // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The - // cache is read-only here — no subprocess is spawned. + // Adapter-availability drift (codex only) contributes its own synthetic + // entry, so an out-of-band adapter change (manual npm install/downgrade) + // that Phase-1 auto-restart doesn't cover still shows the user what moved. + // The cache is read-only here — no subprocess is spawned. // - // Global config drives both the restart-drift hash and descriptor env - // layering below — the caller loads it once and passes it in, so + // Global config drives both the prospective snapshot and the descriptor + // env layering below — the caller loads it once and passes it in, so // list-style callers pay one disk read per call rather than one per record. - let needs_restart = pair_key - .as_ref() - .and_then(|key| runtimes.get(key).map(|runtime| (key, runtime))) - .is_some_and(|(key, runtime)| { - let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); - let hash_drift = runtime.spawn_config_hash - != crate::managed_agents::spawn_hash::spawn_config_hash( - record, - personas, - &teams_for_hash, - &key.relay_url, - global_config, - ); - let availability_drift = super::availability_drift( - runtime.adapter_availability.as_ref(), - super::adapter_availability_cached(), - ); - // An orphan can never be restarted successfully — - // `spawn_agent_child` refuses it before any process side effect — - // so `needs_restart` must never fire for one regardless of hash or - // availability drift. Surfacing "Restart required" here would offer - // an action guaranteed to fail; the UI shows `persona_orphaned` - // instead (see `ManagedAgentSummary::persona_orphaned`). - restart_eligible(persona_orphaned, hash_drift, availability_drift) - }); + // The prospective side is computed only for a tracked pair: it costs a + // teams-store read, and an unstamped agent has nothing to compare against. + let tracked_spawn = pair_key.as_ref().zip(pair_runtime).map(|(key, runtime)| { + let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); + let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + record, + personas, + &teams, + &key.relay_url, + global_config, + ); + (runtime, current) + }); + let restart_diff = crate::managed_agents::spawn_snapshot::eligible_restart_diff( + persona_orphaned, + tracked_spawn.as_ref().map(|(runtime, current)| { + crate::managed_agents::spawn_snapshot::TrackedSpawnState { + stamped: &runtime.spawn_config, + current, + stamped_availability: runtime.adapter_availability.as_ref(), + current_availability: super::adapter_availability_cached(), + } + }), + ); + // One vector is the whole truth: badge on ⟺ there is a diff to show. + let needs_restart = !restart_diff.is_empty(); // Resolve the effective harness via the single typed descriptor — same resolver // as spawn, so the UI reflects the persona's current harness (or explicit pin). @@ -320,6 +319,7 @@ pub fn build_managed_agent_summary( persona_out_of_date, persona_orphaned, needs_restart, + restart_diff, env_vars: record.env_vars.clone(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), @@ -340,19 +340,6 @@ pub fn build_managed_agent_summary( }) } -/// Pure predicate: should the "Restart required" badge fire? -/// -/// An orphaned linked instance (its persona/definition no longer exists) -/// can never be restarted successfully — `spawn_agent_child` refuses to -/// spawn it before any process side effect. Surfacing "Restart required" -/// for one would offer an action guaranteed to fail, so this always -/// returns `false` for an orphan regardless of drift. Extracted for unit -/// testing without `AppHandle`/global state, following the -/// `availability_drift` pattern in `discovery.rs`. -fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool { - !persona_orphaned && (hash_drift || availability_drift) -} - pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, @@ -363,18 +350,14 @@ pub fn find_managed_agent_mut<'a>( .ok_or_else(|| format!("agent {pubkey} not found")) } -/// Pure decision function for the inbound author gate env vars. -/// -/// Returns the env vars to **set** and the env vars to **remove**. Removal is +/// Pure decision function for the inbound author gate env vars. Returns the +/// env vars to **set** and the env vars to **remove**. Removal is /// belt-and-suspenders: an inherited parent env var must not leak into a -/// child agent and silently change its security posture. -/// -/// The `owner_hex` argument is the current workspace owner pubkey. It's used -/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the -/// harness's owner cache stays empty and `owner-only` / `allowlist` modes -/// drop everything. -/// -/// Returns `Err(...)` if the record's allowlist fails validation. The harness +/// child agent and silently change its security posture. `owner_hex` is the +/// current workspace owner pubkey, used as a fallback for legacy records +/// (`auth_tag.is_none()`) — without it, the harness's owner cache stays +/// empty and `owner-only` / `allowlist` modes drop everything. Returns +/// `Err(...)` if the record's allowlist fails validation — the harness /// validates too, but doing it here means we never spawn a doomed process. pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, @@ -445,11 +428,11 @@ pub(crate) fn configure_runtime_cli( } /// Spawn an agent process without holding any locks on records or runtimes. -/// Returns the child process and log path on success. The caller is responsible -/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. -/// -/// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy -/// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. +/// Returns the child process and log path on success. The caller is +/// responsible for updating `ManagedAgentRecord` fields and inserting into +/// the runtimes map. `owner_hex`: the workspace owner's pubkey, used as a +/// fallback for legacy records with no NIP-OA `auth_tag` — see +/// `build_respond_to_env`. pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, @@ -473,7 +456,7 @@ pub fn spawn_agent_child( let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — - // the single source both the env writes below and `spawn_config_hash` + // the single source both the env writes below and the spawn-config snapshot // read from. Previously prompt was read from the record's own (possibly // stale, Phase-A-snapshot) bytes while model/provider were resolved live // from `personas`; a definition edit landing between a caller's snapshot @@ -490,8 +473,9 @@ pub fn spawn_agent_child( // Single typed resolver: validates runtime id (dangling harness → Err), resolves // command, args (instance wins over definition default), and the full env layer stack. - // This is the sole path for harness-definition lookup — spawn, hash, summary, and - // model probes all consume this descriptor rather than assembling values inline. + // This is the sole path for harness-definition lookup — spawn, snapshot, + // summary, and model probes all consume this descriptor rather than + // assembling values inline. // Like the orphan refusal above, this runs before any side effect so a refused // spawn leaves no trace. let descriptor = @@ -597,21 +581,17 @@ pub fn spawn_agent_child( } // ── Readiness check: set setup-payload if agent is not ready ───────────── - // // Build the effective env the agent would have at start-time, run the // readiness predicate, and if anything is missing, serialize the payload // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup // and enters the minimal setup-listener mode instead of the agent pool. - // // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env // cannot set it, but we also explicitly remove it after writing user env // to guard against the parent-process environment. We then set it only // when desktop has computed NotReady — the desktop is the sole readiness // source and buzz-acp only transports the payload. - // // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } - // // `spawned_setup_mode` is captured outside the block so it can be stamped // on `ManagedAgentProcess` — used by `install_acp_runtime` to target only // stuck agents for auto-restart. @@ -735,7 +715,7 @@ pub fn spawn_agent_child( } } } - let team_instructions = super::spawn_hash::effective_team_instructions(record, &teams); + let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams); if let Some(instructions) = &team_instructions { command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); } else { @@ -743,11 +723,10 @@ pub fn spawn_agent_child( } // Prompt, model, and provider all come from the single `effective_cfg` - // resolved at the top of this function — the SAME resolve `spawn_config_hash` - // performs below, so env write and restart badge cannot disagree. Linked + // resolved at the top of this function — the SAME resolve the spawn-config + // snapshot reads, so env write and restart badge cannot disagree. Linked // instances never consult the record's own model/provider/prompt bytes; // definition-less instances fall back to their own fields, then global. - // // Derive the mesh decision BEFORE moving fields out — `relay_mesh_model_id` // is the single authoritative gate; the mesh-llm block below MUST use it // rather than re-deriving from `effective_provider` to keep preflight and @@ -770,8 +749,9 @@ pub fn spawn_agent_child( } // Session title for the harness to pass out-of-band on `session/new`. The // adapter names the session after it; it never reaches the prompt, so this - // is display metadata only. `spawn_config_hash` hashes the same resolve, so - // a rename raises the restart badge instead of leaving the process stale. + // is display metadata only. The spawn-config snapshot records the same + // resolve, so a rename raises the restart badge instead of leaving the + // process stale. if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { command.env(SESSION_TITLE_ENV_VAR, title); } else { @@ -814,11 +794,9 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); // ── Git credential helper for Buzz relay ────────────────────────── - // // Agents need to clone/push repos hosted on the Buzz relay's git // server, which authenticates via NIP-98. The `git-credential-nostr` // binary signs auth events using the agent's nostr key. - // // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no // filesystem writes) scoped to the relay's git URL so we don't // interfere with other remotes (e.g. GitHub). @@ -881,6 +859,22 @@ pub fn spawn_agent_child( .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); + // Stamp the effective spawn config from the values that populated the + // `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let + // a persona/harness/global edit landing in between stamp the NEW config + // onto a child running the OLD one, silently suppressing the badge. + let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( + super::spawn_snapshot::SpawnConfigInputs { + record, + descriptor: &descriptor, + relay_url: &effective_relay_url, + team_instructions: team_instructions.as_deref(), + system_prompt: effective_prompt.as_deref(), + model: effective_model.as_deref(), + provider: effective_provider.as_deref(), + }, + ); + // Spawn the harness in its own process group so we can kill the entire // tree (harness + MCP servers + agent subprocesses) on shutdown. #[cfg(unix)] @@ -906,18 +900,6 @@ pub fn spawn_agent_child( ) })?; - // Stamp the effective spawn config so the summary builder can flag - // needs_restart when disk state drifts from what this process runs. - // `effective_relay_url` is already resolved, and resolution is idempotent, - // so it serves as the workspace-relay input here. - let spawn_config_hash = super::spawn_hash::spawn_config_hash( - record, - &personas, - &teams, - &effective_relay_url, - &global, - ); - // Stamp the adapter availability for runtimes with a version gate (codex // only). The summary builder compares this against the current cached value // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). @@ -940,7 +922,7 @@ pub fn spawn_agent_child( return Ok(super::process_lifecycle::finish_spawn( child, log_path, - spawn_config_hash, + spawn_config, spawned_setup_mode, spawned_adapter_availability, start_nonce, @@ -950,7 +932,7 @@ pub fn spawn_agent_child( Ok(crate::managed_agents::ManagedAgentProcess { child, log_path, - spawn_config_hash, + spawn_config, setup_mode: spawned_setup_mode, adapter_availability: spawned_adapter_availability, start_nonce, diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 96ac73e34..26d210e5c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -25,7 +25,7 @@ pub(crate) fn runtime_metadata_env_vars<'a>( } /// Env var carrying the session title to the harness. Shared with -/// `spawn_hash` so the restart badge hashes the same key the spawn writes. +/// `spawn_snapshot` so the restart badge records the same key the spawn writes. pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE"; /// Resolve the session title for an agent: its `display_name` when it has one, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f..bea4b1c3e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1271,7 +1271,13 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun let process = crate::managed_agents::ManagedAgentProcess { child, log_path: std::path::PathBuf::new(), - spawn_config_hash: 0, + spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &minimal_record(&"cc".repeat(32)), + &[], + &[], + "wss://relay.example", + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce".to_string(), @@ -1280,37 +1286,3 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun }; crate::managed_agents::ManagedAgentPairRuntime::starting(process) } - -// ── restart_eligible tests ────────────────────────────────────────────── - -#[test] -fn restart_eligible_true_when_non_orphan_has_hash_drift() { - assert!(super::restart_eligible(false, true, false)); -} - -#[test] -fn restart_eligible_true_when_non_orphan_has_availability_drift() { - assert!(super::restart_eligible(false, false, true)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_hash_drift() { - // An orphan can never be restarted successfully — spawn refuses it — - // so hash drift alone must not surface "Restart required". - assert!(!super::restart_eligible(true, true, false)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_availability_drift() { - assert!(!super::restart_eligible(true, false, true)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_no_drift() { - assert!(!super::restart_eligible(true, false, false)); -} - -#[test] -fn restart_eligible_false_when_non_orphan_has_no_drift() { - assert!(!super::restart_eligible(false, false, false)); -} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs deleted file mode 100644 index 648cc62bb..000000000 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Spawn-time config hash for the restart-required badge. -//! -//! [`spawn_config_hash`] digests the *effective spawned values* — what a -//! process launch of `record` would actually receive — so the UI can compare -//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at -//! spawn) against a recomputation from current disk state and show a -//! "restart required" badge only when a restart would change what runs. -//! -//! Scope rules (decided in #centralize-personas-and-agents, revised in PR -//! #1602 review): -//! - Inputs mirror what a start would actually run: the start/restore paths -//! re-snapshot the linked persona's prompt/model/provider/env onto the -//! record immediately before spawning (`start_local_agent_with_preflight`, -//! `restore_managed_agents_on_launch`), so persona edits to those fields DO -//! apply on a plain restart and are hashed via the same prospective -//! re-snapshot. Harness command, args/mcp, env layering, and the record -//! fields the spawn env writes read are hashed as spawn resolves them. -//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`): -//! every record spawns against the active workspace relay (legacy per-record -//! pins are ignored), so a workspace relay change means a restart would -//! change what runs. -//! - Channel membership is not an input: agents pick up channel changes live -//! (#1468), never via restart. -//! -//! The hash never crosses a process or persistence boundary, so -//! `DefaultHasher` (not stable across Rust releases) is sufficient. - -use std::hash::{DefaultHasher, Hash, Hasher}; - -use super::{ - effective_config::{resolve_effective_config, EffectiveConfigResult}, - known_acp_runtime, normalize_agent_args, - persona_events::preview_prospective_persona_snapshot, - runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, - types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, - GlobalAgentConfig, -}; - -/// Resolve the current instructions for this instance's deployment-time team binding. -/// A deleted team deliberately degrades to no team section. -pub(crate) fn effective_team_instructions( - record: &ManagedAgentRecord, - teams: &[TeamRecord], -) -> Option { - teams - .iter() - .find(|team| Some(team.id.as_str()) == record.team_id.as_deref()) - .and_then(|team| team.instructions.as_deref()) - .map(str::trim) - .filter(|instructions| !instructions.is_empty()) - .map(str::to_string) -} - -/// Digest the effective spawn configuration of `record` under the current -/// `personas`, resolving a blank record relay against `workspace_relay`. -/// Pure — no `AppHandle`, no disk, no keyring. -pub(crate) fn spawn_config_hash( - record: &ManagedAgentRecord, - personas: &[AgentDefinition], - teams: &[TeamRecord], - workspace_relay: &str, - global: &GlobalAgentConfig, -) -> u64 { - // Prospective re-snapshot: apply the same `apply_persona_snapshot` the - // start/restore paths run right before spawning, so the hash covers what a - // restart would actually run. Idempotent, so the spawn-time stamp - // (post-snapshot record) and later recomputes (persisted record) agree - // when nothing changed. The persona env itself reaches the hash through - // the descriptor's layered env below; `persona_source_version` is set on - // the clone but is not a hash input. - let record = preview_prospective_persona_snapshot(record, personas); - let record = &record; - - // Resolve command, args, and env via the single typed descriptor — same path - // as spawn_agent_child. Dangling harness id falls back to the infallible - // record_agent_command (no-op: a dangling harness can't be spawned, so the - // hash never matters for that agent). - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) - .unwrap_or_else(|_| { - let cmd = crate::managed_agents::record_agent_command(record, personas); - let args = normalize_agent_args(&cmd, record.agent_args.clone()); - crate::managed_agents::readiness::EffectiveHarnessDescriptor { - command: cmd, - args, - env: Default::default(), - } - }); - let runtime_meta = known_acp_runtime(&descriptor.command); - - let mut hasher = DefaultHasher::new(); - - // Harness identity and derivations (live-persona-resolved, like spawn). - record.acp_command.hash(&mut hasher); - descriptor.command.hash(&mut hasher); - descriptor.args.hash(&mut hasher); - runtime_meta - .and_then(|r| r.mcp_command) - .unwrap_or("") - .hash(&mut hasher); - - // Effective env layering (baked floor → runtime metadata → definition env - // → global → persona → agent). BTreeMap iteration is ordered, deterministic. - descriptor.env.hash(&mut hasher); - - // Record fields the spawn env writes read directly. The relay is hashed - // resolved: every record spawns on the workspace relay (legacy pins - // ignored), so a workspace relay change must trip the badge. - crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Team instructions use the same resolver as spawn. - effective_team_instructions(record, teams).hash(&mut hasher); - // Prompt, model, and provider all come from ONE `resolve_effective_config` - // call — the SAME resolve `spawn_agent_child` performs for the env write, - // so env write and this badge cannot disagree. An orphaned link (missing - // definition) hashes as if all three were absent: `spawn_agent_child` - // refuses to spawn an orphan regardless, so this is a display-only - // convenience, not the spawn gate. - let (resolved_prompt, resolved_model, resolved_provider) = - match resolve_effective_config(record, personas, global) { - EffectiveConfigResult::Resolved(cfg) => { - (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) - } - EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), - }; - resolved_prompt.hash(&mut hasher); - resolved_model.hash(&mut hasher); - resolved_provider.hash(&mut hasher); - // Session title: the same resolve `spawn_agent_child` performs for its env - // write, so a rename raises the restart badge. Skipped when a user env - // override shadows it — spawn writes the title BEFORE the user env layer, - // so the override is what actually runs, and it already reaches this hash - // through `descriptor.env` above. Hashing the record-derived value under an - // override would badge a rename that changes nothing. - let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) - .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) - .flatten(); - effective_session_title.hash(&mut hasher); - record.auth_tag.hash(&mut hasher); - record.respond_to.as_str().hash(&mut hasher); - // The allowlist is hashed as the env receives it: spawn sets - // BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized - // (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits - // that don't survive normalization, or edits while another mode is - // active, must not badge. A list spawn would reject hashes raw: the - // stamped hash comes from a successful spawn, so any invalid edit - // correctly compares unequal. - if record.respond_to == super::types::RespondTo::Allowlist { - super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) - .unwrap_or_else(|_| record.respond_to_allowlist.clone()) - .hash(&mut hasher); - } - record.idle_timeout_seconds.hash(&mut hasher); - record.max_turn_duration_seconds.hash(&mut hasher); - record.parallelism.hash(&mut hasher); - - hasher.finish() -} - -#[cfg(test)] -mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs new file mode 100644 index 000000000..73a006e70 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -0,0 +1,263 @@ +//! Spawn-time config snapshot for the restart-required badge. +//! +//! [`SpawnConfigSnapshot`] captures the *effective spawned values* — what a +//! process launch of a record would actually receive. The running process +//! stamps one on [`super::ManagedAgentProcess`] at spawn; the summary builder +//! recomputes a prospective one from current disk state and compares. Drift +//! means a restart would change what runs, and the field-by-field difference +//! is what the UI shows (see [`diff`]). +//! +//! Scope rules (decided in #centralize-personas-and-agents, revised in PR +//! #1602 review): +//! - Inputs mirror what a start would actually run: the start/restore paths +//! re-snapshot the linked persona's prompt/model/provider/env onto the +//! record immediately before spawning (`start_local_agent_with_preflight`, +//! `restore_managed_agents_on_launch`), so persona edits to those fields DO +//! apply on a plain restart and reach the prospective snapshot via the same +//! re-snapshot. Harness command, args/mcp, env layering, and the record +//! fields the spawn env writes read are captured as spawn resolves them. +//! - The relay URL is captured in resolved form (`effective_agent_relay_url`): +//! every record spawns against the active workspace relay (legacy per-record +//! pins are ignored), so a workspace relay change means a restart would +//! change what runs. +//! - Channel membership is not an input: agents pick up channel changes live +//! (#1468), never via restart. +//! +//! The snapshot never crosses a process or persistence boundary — it is +//! runtime state only, held on the running `ManagedAgentProcess`. + +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::{ + effective_config::{resolve_effective_config, EffectiveConfigResult}, + known_acp_runtime, normalize_agent_args, + persona_events::preview_prospective_persona_snapshot, + readiness::EffectiveHarnessDescriptor, + runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, + types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, + GlobalAgentConfig, +}; + +pub(crate) mod diff; +pub(crate) use diff::{eligible_restart_diff, RestartDiffEntry, TrackedSpawnState}; + +/// Resolve the current instructions for this instance's deployment-time team binding. +/// A deleted team deliberately degrades to no team section. +pub(crate) fn effective_team_instructions( + record: &ManagedAgentRecord, + teams: &[TeamRecord], +) -> Option { + teams + .iter() + .find(|team| Some(team.id.as_str()) == record.team_id.as_deref()) + .and_then(|team| team.instructions.as_deref()) + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + .map(str::to_string) +} + +/// The already-resolved values a spawn feeds into its `Command`. +/// +/// Taking them rather than re-resolving is what makes the stamp describe the +/// process that was actually launched: a persona/harness/global edit landing +/// between spawn's resolution and the stamp can no longer suppress the badge. +pub(crate) struct SpawnConfigInputs<'a> { + pub record: &'a ManagedAgentRecord, + pub descriptor: &'a EffectiveHarnessDescriptor, + /// Resolved workspace/pair relay — never the record's legacy pin. + pub relay_url: &'a str, + pub team_instructions: Option<&'a str>, + pub system_prompt: Option<&'a str>, + pub model: Option<&'a str>, + pub provider: Option<&'a str>, +} + +/// The effective spawn configuration of one managed-agent process. +/// +/// Serialization invariants (load-bearing — the drift comparison and the diff +/// walk both read `canonical()`): +/// - plain derived `Serialize`: no `flatten`, no `skip_serializing_if`, no +/// custom or fallible field serializers, no colliding serialized names, so +/// every field is always present on both sides of a comparison; +/// - `Option::None` serializes as JSON `null`; a *missing* key is reserved for +/// dynamic-map membership (`env.` added/removed); +/// - arrays are atomic leaves — `args` and `respond_to_allowlist` compare and +/// render whole, never element-wise. +/// +/// `Debug` is implemented by hand: [`ManagedAgentProcess`] derives `Debug`, so +/// a derived impl here would print env values, auth tags, and CLI arguments. +/// +/// [`ManagedAgentProcess`]: super::ManagedAgentProcess +#[derive(Clone, Serialize)] +pub(crate) struct SpawnConfigSnapshot { + /// The ACP harness binary the desktop launches (`buzz-acp`). + pub acp_command: String, + /// The effective agent command the harness drives. + pub command: String, + pub args: Vec, + /// Catalog-derived from `command`; `""` when the runtime has none. + pub mcp_command: String, + /// Fully layered process env: baked floor -> runtime metadata -> + /// definition -> global -> persona -> agent. + pub env: BTreeMap, + pub relay_url: String, + pub team_instructions: Option, + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + /// `None` when a user env override shadows `BUZZ_ACP_SESSION_TITLE`: spawn + /// writes the title BEFORE the user env layer, so the override is what + /// actually runs and it already reaches this snapshot through `env`. + /// Capturing the record-derived value under an override would badge a + /// rename that changes nothing. + pub session_title: Option, + pub auth_tag: Option, + pub respond_to: String, + /// `None` outside allowlist mode — spawn sets + /// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` only there, so edits to a dormant list + /// must not badge. Normalized (trim/lowercase/dedup) as the env receives + /// it, so edits that don't survive normalization must not badge either. + pub respond_to_allowlist: Option>, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, + pub parallelism: u32, +} + +impl SpawnConfigSnapshot { + /// Assemble the snapshot from values a spawn has already resolved. + pub(crate) fn from_inputs(inputs: SpawnConfigInputs<'_>) -> Self { + let SpawnConfigInputs { + record, + descriptor, + relay_url, + team_instructions, + system_prompt, + model, + provider, + } = inputs; + Self { + acp_command: record.acp_command.clone(), + command: descriptor.command.clone(), + args: descriptor.args.clone(), + mcp_command: known_acp_runtime(&descriptor.command) + .and_then(|runtime| runtime.mcp_command) + .unwrap_or("") + .to_string(), + env: descriptor.env.clone(), + relay_url: relay_url.to_string(), + team_instructions: team_instructions.map(str::to_string), + system_prompt: system_prompt.map(str::to_string), + model: model.map(str::to_string), + provider: provider.map(str::to_string), + session_title: (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) + .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) + .flatten(), + auth_tag: record.auth_tag.clone(), + respond_to: record.respond_to.as_str().to_string(), + respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then( + || { + // A list spawn would reject is captured raw: the stamped + // snapshot comes from a successful spawn, so any invalid + // edit correctly compares unequal. + super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) + .unwrap_or_else(|_| record.respond_to_allowlist.clone()) + }, + ), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + parallelism: record.parallelism, + } + } + + /// Canonical JSON projection — the single representation both the drift + /// comparison and the diff walk read, so a lit badge always has a + /// non-empty diff and vice versa. + /// + /// Infallible by the serialization invariants documented on the struct + /// (plain derive over strings, scalars, string maps, and string vectors); + /// a failure here is a broken invariant, never a runtime condition, so it + /// must not degrade into an empty diff. + pub(crate) fn canonical(&self) -> serde_json::Value { + serde_json::to_value(self).expect("SpawnConfigSnapshot serializes infallibly") + } +} + +impl std::fmt::Debug for SpawnConfigSnapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SpawnConfigSnapshot({})", + diff::redacted_canonical(&self.canonical()) + ) + } +} + +/// Snapshot the effective spawn configuration `record` would get if it were +/// started right now under the current `personas`/`teams`/`global`, resolving +/// a blank record relay against `workspace_relay`. +/// +/// Pure — no `AppHandle`, no disk, no keyring. This is the *prospective* side +/// of the comparison; the stamped side is built at spawn from the values that +/// actually fed the child's `Command`. +pub(crate) fn prospective_spawn_config_snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, +) -> SpawnConfigSnapshot { + // Prospective re-snapshot: apply the same `apply_persona_snapshot` the + // start/restore paths run right before spawning, so this describes what a + // restart would actually run. Idempotent, so a spawn-time stamp taken + // after those paths saved the record compares equal when nothing changed. + // The persona env itself arrives through the descriptor's layered env + // below; `persona_source_version` is set on the clone but is not an input. + let record = preview_prospective_persona_snapshot(record, personas); + let record = &record; + + // Resolve command, args, and env via the single typed descriptor — same + // path as spawn_agent_child. Dangling harness id falls back to the + // infallible record_agent_command (no-op: a dangling harness can't be + // spawned, so the snapshot never matters for that agent). + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) + .unwrap_or_else(|_| { + let command = crate::managed_agents::record_agent_command(record, personas); + let args = normalize_agent_args(&command, record.agent_args.clone()); + EffectiveHarnessDescriptor { + command, + args, + env: Default::default(), + } + }); + + // Prompt, model, and provider all come from ONE `resolve_effective_config` + // call — the SAME resolve `spawn_agent_child` performs for the env write, + // so env write and this badge cannot disagree. An orphaned link (missing + // definition) resolves as if all three were absent: `spawn_agent_child` + // refuses to spawn an orphan regardless, and `eligible_restart_diff` + // suppresses the badge for one. + let (prompt, model, provider) = match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + + SpawnConfigSnapshot::from_inputs(SpawnConfigInputs { + record, + descriptor: &descriptor, + // Resolved, not stored: every record spawns on the workspace relay + // (legacy pins ignored), so a workspace relay change must badge. + relay_url: &crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay), + team_instructions: effective_team_instructions(record, teams).as_deref(), + system_prompt: prompt.as_deref(), + model: model.as_deref(), + provider: provider.as_deref(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs new file mode 100644 index 000000000..a61eb92e2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs @@ -0,0 +1,307 @@ +//! Redacted field-by-field diff of two [`SpawnConfigSnapshot`]s. +//! +//! The walk is generic over the snapshot's canonical JSON: it compares leaves +//! by path and emits one entry per inequality. Adding a field to +//! [`SpawnConfigSnapshot`] therefore reaches the UI with no change here — the +//! only per-path knowledge in this module is [`policy_for`], which decides how +//! a leaf may be *shown*, never which leaves are compared. +//! +//! Raw values drive comparison; redaction happens strictly afterwards, when +//! the serializable entry is built. Comparing masked forms would let two +//! secrets with colliding suffixes read as "no drift". + +use serde::Serialize; +use serde_json::{Map, Value}; + +use super::SpawnConfigSnapshot; +use crate::managed_agents::AcpAvailabilityStatus; + +/// Synthetic field id for adapter-availability drift, which lives outside the +/// snapshot: it describes the environment around the process, not the config +/// the process was spawned with. +const ADAPTER_AVAILABILITY_FIELD: &str = "adapter_availability"; + +const MASK: &str = "••••"; + +/// One changed field. `field` is a dotted path built from serde field names, +/// with dynamic map keys appended verbatim (`env.OPENAI_API_KEY`). The UI +/// humanizes it generically and must never switch on its value. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RestartDiffEntry { + pub field: String, + pub change: RestartChange, +} + +/// How a changed field is presented. The UI switches on `kind` — a closed set +/// — and renders any `field` path. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RestartChange { + /// Safe scalar or array shown verbatim. `null` means absent. + Value { before: Value, after: Value }, + /// Large text shown as character counts only. `null` means absent. + Text { + before_chars: Option, + after_chars: Option, + }, + /// Secret-bearing leaf. `null` means absent. + Masked { + before: Option, + after: Option, + }, + /// Dynamic-map key present only on the new side. No payload — the value + /// would be secret-bearing and the key name alone is the useful signal. + Added, + /// Dynamic-map key present only on the old side. + Removed, +} + +/// How a leaf at `path` may be displayed. +#[derive(Clone, Copy, PartialEq)] +enum MaskPolicy { + /// Shown verbatim. + Plain, + /// Character counts only. + Text, + /// `••••` plus the last four characters when longer than eight. + MaskedSuffix, + /// `••••` and nothing else. + MaskedBare, +} + +/// The single redaction authority: the wire diff and the snapshot's `Debug` +/// both route every leaf through this. +/// +/// A new snapshot field needs an arm here only if it can carry a credential or +/// is too large to render; everything else falls through to `Plain`. +fn policy_for(path: &str) -> MaskPolicy { + match path { + // Arbitrary user text — a rendered before/after would be unbounded as + // well as unreadable. + "system_prompt" | "team_instructions" => MaskPolicy::Text, + // Arbitrary CLI arguments: `--token=...` is legal, so no part of the + // value may be disclosed. Same for the relay URL — `normalize_relay_url` + // rejects userinfo but deliberately preserves query strings, so + // `wss://relay.example/ws?token=...` is a valid value. + "args" | "relay_url" => MaskPolicy::MaskedBare, + // NIP-OA auth tag: a credential, but a suffix tells the user which tag + // they are looking at. + "auth_tag" => MaskPolicy::MaskedSuffix, + // Env values: consult the shared allowlist. Allowlisted keys (e.g. + // `BUZZ_AGENT_THINKING_EFFORT`) render plain so the user sees the + // actual enum values; every other env key stays masked. + _ if path.starts_with("env.") => { + let key = &path[4..]; + if crate::managed_agents::is_safe_to_reveal(key) { + MaskPolicy::Plain + } else { + MaskPolicy::MaskedSuffix + } + } + // Plain arm. Every path reaching it is already rendered verbatim in + // the runtime UI today: + // acp_command / command / mcp_command — resolved binary names + // session_title — display chrome + // model / provider — catalog ids + // respond_to / respond_to_allowlist — gate mode + pubkeys + // idle_timeout_seconds / max_turn_duration_seconds / parallelism + // — numeric limits + // adapter_availability — an enum variant name + _ => MaskPolicy::Plain, + } +} + +/// `••••` plus the last four characters, or a bare `••••` when the value is +/// short enough that a suffix would disclose too much of it. +/// +/// Character-based throughout: byte slicing can panic on a multi-byte value or +/// disclose the wrong suffix. +fn mask(value: &str) -> String { + let chars: Vec = value.chars().collect(); + match chars.len() { + len if len > 8 => format!("{MASK}{}", chars[len - 4..].iter().collect::()), + _ => MASK.to_string(), + } +} + +/// Character count of a text leaf; `None` when the leaf is absent. +fn char_count(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.chars().count()), + // Fail closed on an unexpected shape: count it, never show it. + other => Some(other.to_string().chars().count()), + } +} + +/// Masked rendering of a leaf; `None` when the leaf is absent. +fn masked(policy: MaskPolicy, value: &Value) -> Option { + match (policy, value) { + (_, Value::Null) => None, + (MaskPolicy::MaskedSuffix, Value::String(text)) => Some(mask(text)), + // Fail closed: an unexpected shape under a redacting policy still + // redacts rather than disclosing the raw value. + _ => Some(MASK.to_string()), + } +} + +fn change_for(policy: MaskPolicy, before: &Value, after: &Value) -> RestartChange { + match policy { + MaskPolicy::Plain => RestartChange::Value { + before: before.clone(), + after: after.clone(), + }, + MaskPolicy::Text => RestartChange::Text { + before_chars: char_count(before), + after_chars: char_count(after), + }, + MaskPolicy::MaskedSuffix | MaskPolicy::MaskedBare => RestartChange::Masked { + before: masked(policy, before), + after: masked(policy, after), + }, + } +} + +/// Lexicographically sorted union of both maps' keys, so entry order — and +/// therefore the UI's "first N plus and-N-more" truncation — is stable. +fn key_union<'a>(before: &'a Map, after: &'a Map) -> Vec<&'a str> { + let mut keys: Vec<&str> = before + .keys() + .chain(after.keys()) + .map(String::as_str) + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +fn child_path(parent: &str, key: &str) -> String { + if parent.is_empty() { + key.to_string() + } else { + format!("{parent}.{key}") + } +} + +fn walk( + path: &str, + before: Option<&Value>, + after: Option<&Value>, + out: &mut Vec, +) { + match (before, after) { + (before, after) if before == after => {} + // Present on one side only. Struct fields are always present (`None` + // serializes as `null`), so this is dynamic-map membership. + (None, Some(_)) => out.push(RestartDiffEntry { + field: path.to_string(), + change: RestartChange::Added, + }), + (Some(_), None) => out.push(RestartDiffEntry { + field: path.to_string(), + change: RestartChange::Removed, + }), + (Some(Value::Object(before)), Some(Value::Object(after))) => { + for key in key_union(before, after) { + walk(&child_path(path, key), before.get(key), after.get(key), out); + } + } + // Everything else is a leaf: scalars, and arrays (atomic — `args` + // changed as a whole, never `args.0`). + (before, after) => out.push(RestartDiffEntry { + field: path.to_string(), + change: change_for( + policy_for(path), + before.unwrap_or(&Value::Null), + after.unwrap_or(&Value::Null), + ), + }), + } +} + +/// The redacted diff of two snapshots, in stable path order. +fn diff(before: &SpawnConfigSnapshot, after: &SpawnConfigSnapshot) -> Vec { + let mut entries = Vec::new(); + walk( + "", + Some(&before.canonical()), + Some(&after.canonical()), + &mut entries, + ); + entries +} + +fn availability_value(status: Option<&AcpAvailabilityStatus>) -> Value { + status + .and_then(|status| serde_json::to_value(status).ok()) + .unwrap_or(Value::Null) +} + +/// What a tracked runtime was launched with, paired with what a launch would +/// use now. Absent (`None` at the call site) for every agent this workspace +/// tracks no live pair for — stopped, or `runtime_pid`-adopted across an app +/// restart, whose spawn config was never stamped and so can never be shown to +/// have drifted. +pub(crate) struct TrackedSpawnState<'a> { + pub stamped: &'a SpawnConfigSnapshot, + pub current: &'a SpawnConfigSnapshot, + pub stamped_availability: Option<&'a AcpAvailabilityStatus>, + pub current_availability: Option, +} + +/// The final restart-diff for one agent — the single source of both the wire +/// field and the badge, which is `!result.is_empty()`. +/// +/// Empty for an un-stamped agent (see [`TrackedSpawnState`]) and for an +/// orphaned instance: `spawn_agent_child` refuses to spawn an orphan before +/// any side effect, so "Restart required" would offer an action guaranteed to +/// fail. The UI surfaces `persona_orphaned` instead. +pub(crate) fn eligible_restart_diff( + persona_orphaned: bool, + tracked: Option>, +) -> Vec { + let Some(tracked) = tracked.filter(|_| !persona_orphaned) else { + return Vec::new(); + }; + let mut entries = diff(tracked.stamped, tracked.current); + if crate::managed_agents::availability_drift( + tracked.stamped_availability, + tracked.current_availability.clone(), + ) { + entries.push(RestartDiffEntry { + field: ADAPTER_AVAILABILITY_FIELD.to_string(), + change: RestartChange::Value { + before: availability_value(tracked.stamped_availability), + after: availability_value(tracked.current_availability.as_ref()), + }, + }); + } + entries +} + +/// The canonical snapshot with every leaf passed through [`policy_for`], +/// rendered as JSON text. Backs `SpawnConfigSnapshot`'s manual `Debug` so a +/// log line can never disclose what the wire diff redacts. +pub(crate) fn redacted_canonical(value: &Value) -> String { + fn redact(path: &str, value: &Value) -> Value { + match value { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, child)| (key.clone(), redact(&child_path(path, key), child))) + .collect(), + ), + leaf => match policy_for(path) { + MaskPolicy::Plain => leaf.clone(), + MaskPolicy::Text => char_count(leaf).map_or(Value::Null, |count| { + Value::String(format!("<{count} chars>")) + }), + policy => masked(policy, leaf).map_or(Value::Null, Value::String), + }, + } + } + redact("", value).to_string() +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs new file mode 100644 index 000000000..a7a8cab93 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -0,0 +1,572 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const SECRET: &str = "sk-live-SENTINEL-0000"; +const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL"; + +/// Every field populated, so mutating one to `None` is a real change and the +/// coverage guard below sees the full serialized key set. +fn base() -> SpawnConfigSnapshot { + SpawnConfigSnapshot { + acp_command: "buzz-acp".into(), + command: "goose".into(), + args: vec!["--mode".into(), "acp".into()], + mcp_command: "goose-mcp".into(), + env: BTreeMap::from([ + ("OPENAI_API_KEY".to_string(), SECRET.to_string()), + ("BUZZ_LOG".to_string(), "info".to_string()), + ]), + relay_url: "wss://relay.example".into(), + team_instructions: Some("Team says hello.".into()), + system_prompt: Some("You are a test agent.".into()), + model: Some("gpt-5".into()), + provider: Some("openai".into()), + session_title: Some("Fizz".into()), + auth_tag: Some("tag-abcdefgh".into()), + respond_to: "owner-only".into(), + respond_to_allowlist: Some(vec!["a".repeat(64)]), + idle_timeout_seconds: Some(600), + max_turn_duration_seconds: Some(7200), + parallelism: 1, + } +} + +fn fields(entries: &[RestartDiffEntry]) -> Vec<&str> { + entries.iter().map(|entry| entry.field.as_str()).collect() +} + +fn change_at<'a>(entries: &'a [RestartDiffEntry], field: &str) -> &'a RestartChange { + &entries + .iter() + .find(|entry| entry.field == field) + .unwrap_or_else(|| panic!("no entry for {field}; got {:?}", fields(entries))) + .change +} + +/// One mutation per snapshot field, keyed by the diff path it must produce. +type Mutation = (&'static str, fn(&mut SpawnConfigSnapshot)); + +fn mutations() -> Vec { + vec![ + ("acp_command", |s| s.acp_command = "other-acp".into()), + ("command", |s| s.command = "claude".into()), + ("args", |s| s.args = vec!["--other".into()]), + ("mcp_command", |s| s.mcp_command = String::new()), + ("env.OPENAI_API_KEY", |s| { + s.env + .insert("OPENAI_API_KEY".into(), "sk-live-rotated-9999".into()); + }), + ("relay_url", |s| s.relay_url = "wss://other.example".into()), + ("team_instructions", |s| s.team_instructions = None), + ("system_prompt", |s| s.system_prompt = None), + ("model", |s| s.model = None), + ("provider", |s| s.provider = None), + ("session_title", |s| s.session_title = None), + ("auth_tag", |s| s.auth_tag = None), + ("respond_to", |s| s.respond_to = "anyone".into()), + ("respond_to_allowlist", |s| s.respond_to_allowlist = None), + ("idle_timeout_seconds", |s| s.idle_timeout_seconds = None), + ("max_turn_duration_seconds", |s| { + s.max_turn_duration_seconds = None + }), + ("parallelism", |s| s.parallelism = 8), + ] +} + +#[test] +fn every_field_mutation_drifts_the_canonical_value_and_names_that_field() { + for (field, mutate) in mutations() { + let before = base(); + let mut after = base(); + mutate(&mut after); + + assert_ne!( + before.canonical(), + after.canonical(), + "{field}: mutation must move the canonical value the badge compares" + ); + assert_eq!( + fields(&diff(&before, &after)), + vec![field], + "{field}: mutation must produce exactly that field's entry" + ); + // Both directions: `None -> Some` must be as visible as `Some -> None`. + assert_eq!( + fields(&diff(&after, &before)), + vec![field], + "{field}: reverse mutation must be equally visible" + ); + } +} + +#[test] +fn mutation_table_covers_every_serialized_field() { + let covered: BTreeSet<&str> = mutations() + .iter() + .map(|(field, _)| field.split('.').next().expect("non-empty path")) + .collect(); + let canonical = base().canonical(); + let serialized: BTreeSet<&str> = canonical + .as_object() + .expect("snapshot serializes as an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + covered, serialized, + "add a mutation row for every new snapshot field" + ); +} + +#[test] +fn identical_snapshots_produce_no_entries() { + assert!(diff(&base(), &base()).is_empty()); +} + +#[test] +fn env_map_insertion_order_is_not_drift() { + let mut reordered = base(); + reordered.env = base().env.into_iter().rev().collect(); + assert!(diff(&base(), &reordered).is_empty()); +} + +#[test] +fn entries_are_ordered_lexicographically_by_path() { + let mut after = base(); + after.parallelism = 4; + after.command = "claude".into(); + after.env.insert("ZZZ".into(), "1".into()); + after.env.insert("AAA".into(), "1".into()); + assert_eq!( + fields(&diff(&base(), &after)), + vec!["command", "env.AAA", "env.ZZZ", "parallelism"] + ); +} + +// ── map membership vs. nullable struct fields ──────────────────────────── + +#[test] +fn env_key_insertion_is_added_without_a_payload() { + let mut after = base(); + after.env.insert("NEW_KEY".into(), SECRET.into()); + assert_eq!( + change_at(&diff(&base(), &after), "env.NEW_KEY"), + &RestartChange::Added + ); +} + +#[test] +fn env_key_removal_is_removed_without_a_payload() { + let mut after = base(); + after.env.remove("BUZZ_LOG"); + assert_eq!( + change_at(&diff(&base(), &after), "env.BUZZ_LOG"), + &RestartChange::Removed + ); +} + +#[test] +fn cleared_nullable_field_stays_a_value_change_not_a_removal() { + let mut after = base(); + after.model = None; + assert_eq!( + change_at(&diff(&base(), &after), "model"), + &RestartChange::Value { + before: Value::String("gpt-5".into()), + after: Value::Null, + } + ); +} + +#[test] +fn array_field_changes_as_one_atomic_leaf() { + let mut after = base(); + after.respond_to_allowlist = Some(vec!["b".repeat(64)]); + let entries = diff(&base(), &after); + assert_eq!(fields(&entries), vec!["respond_to_allowlist"]); + assert!(matches!( + change_at(&entries, "respond_to_allowlist"), + RestartChange::Value { .. } + )); +} + +#[test] +fn allowlisted_env_key_shows_plain_value() { + // BUZZ_AGENT_THINKING_EFFORT is on the safe-to-reveal allowlist — the user + // must be able to see actual enum values like "medium → high". + let mut before = base(); + before + .env + .insert("BUZZ_AGENT_THINKING_EFFORT".into(), "medium".into()); + let mut after = before.clone(); + after + .env + .insert("BUZZ_AGENT_THINKING_EFFORT".into(), "high".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.BUZZ_AGENT_THINKING_EFFORT"), + &RestartChange::Value { + before: Value::String("medium".into()), + after: Value::String("high".into()), + }, + "allowlisted env key must render plain before/after values" + ); +} + +#[test] +fn allowlisted_env_key_is_case_insensitive() { + // The allowlist comparison is case-insensitive; lowercase path must also + // render plain. + let mut before = base(); + before + .env + .insert("buzz_agent_provider".into(), "anthropic".into()); + let mut after = before.clone(); + after + .env + .insert("buzz_agent_provider".into(), "openai".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.buzz_agent_provider"), + &RestartChange::Value { + before: Value::String("anthropic".into()), + after: Value::String("openai".into()), + }, + "allowlist match must be case-insensitive" + ); +} + +#[test] +fn non_allowlisted_env_key_stays_masked() { + // A key not in the allowlist must remain masked regardless of its name. + let mut after = base(); + after + .env + .insert("SOME_API_KEY".into(), "sk-live-rotated-9999".into()); + // SOME_API_KEY is a new key — starts as Added, not a value change. + // Use an existing env key (OPENAI_API_KEY is in base()) to test masking. + let mut before = base(); + before + .env + .insert("OPENAI_API_KEY".into(), "sk-live-SENTINEL-0000".into()); + let mut after2 = before.clone(); + after2 + .env + .insert("OPENAI_API_KEY".into(), "sk-live-rotated-9999".into()); + assert!( + matches!( + change_at(&diff(&before, &after2), "env.OPENAI_API_KEY"), + RestartChange::Masked { .. } + ), + "non-allowlisted env key must stay masked" + ); +} + +// ── masking policy ─────────────────────────────────────────────────────── + +#[test] +fn env_value_longer_than_eight_chars_shows_a_four_char_suffix() { + let mut after = base(); + after + .env + .insert("OPENAI_API_KEY".into(), "abcdefghi".into()); + assert_eq!( + change_at(&diff(&base(), &after), "env.OPENAI_API_KEY"), + &RestartChange::Masked { + before: Some("••••0000".into()), + after: Some("••••fghi".into()), + } + ); +} + +#[test] +fn env_value_of_exactly_eight_chars_shows_no_suffix() { + let mut before = base(); + before + .env + .insert("OPENAI_API_KEY".into(), "abcdefgh".into()); + let mut after = before.clone(); + after.env.insert("OPENAI_API_KEY".into(), "12345678".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.OPENAI_API_KEY"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn masking_counts_characters_not_bytes() { + // Nine two-byte characters: a byte-based length test would call this + // short, and byte slicing the last four would split a code point. + let mut before = base(); + before.env.insert("K".into(), "áéíóúàèìò".into()); + let mut after = before.clone(); + after.env.insert("K".into(), "áéíóúàèìá".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.K"), + &RestartChange::Masked { + before: Some("••••àèìò".into()), + after: Some("••••àèìá".into()), + } + ); +} + +#[test] +fn args_are_masked_without_any_suffix() { + let mut after = base(); + after.args = vec![format!("--token={SECRET}")]; + assert_eq!( + change_at(&diff(&base(), &after), "args"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn relay_url_is_masked_without_any_suffix() { + let mut after = base(); + after.relay_url = RELAY_WITH_TOKEN.into(); + assert_eq!( + change_at(&diff(&base(), &after), "relay_url"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn auth_tag_is_masked_with_a_suffix() { + let mut after = base(); + after.auth_tag = Some("tag-ijklmnop".into()); + assert_eq!( + change_at(&diff(&base(), &after), "auth_tag"), + &RestartChange::Masked { + before: Some("••••efgh".into()), + after: Some("••••mnop".into()), + } + ); +} + +#[test] +fn large_text_fields_report_character_counts_only() { + let mut after = base(); + after.system_prompt = Some("Longer replacement prompt.".into()); + after.team_instructions = None; + let entries = diff(&base(), &after); + assert_eq!( + change_at(&entries, "system_prompt"), + &RestartChange::Text { + before_chars: Some("You are a test agent.".chars().count()), + after_chars: Some("Longer replacement prompt.".chars().count()), + } + ); + assert_eq!( + change_at(&entries, "team_instructions"), + &RestartChange::Text { + before_chars: Some("Team says hello.".chars().count()), + after_chars: None, + } + ); +} + +// ── secrecy sentinels ──────────────────────────────────────────────────── + +/// A snapshot whose every secret-bearing leaf carries a sentinel. +fn seeded_with_sentinels() -> SpawnConfigSnapshot { + let mut snapshot = base(); + snapshot.relay_url = RELAY_WITH_TOKEN.into(); + snapshot.args = vec![format!("--token={SECRET}")]; + snapshot.auth_tag = Some(SECRET.into()); + snapshot.env.insert("OPENAI_API_KEY".into(), SECRET.into()); + snapshot +} + +/// Every sentinel-bearing leaf changed, plus an added key, so each masking +/// arm has to redact a real value. +fn rotated_sentinels() -> SpawnConfigSnapshot { + let mut snapshot = seeded_with_sentinels(); + snapshot.relay_url = format!("{RELAY_WITH_TOKEN}2"); + snapshot.args = vec![format!("--token={SECRET}2")]; + snapshot.auth_tag = Some(format!("{SECRET}2")); + snapshot + .env + .insert("OPENAI_API_KEY".into(), format!("{SECRET}2")); + snapshot.env.insert("ADDED".into(), SECRET.into()); + snapshot +} + +#[test] +fn no_sentinel_reaches_the_serialized_diff() { + let entries = diff(&seeded_with_sentinels(), &rotated_sentinels()); + assert!(!entries.is_empty(), "fixture must actually drift"); + let wire = serde_json::to_string(&entries).expect("diff serializes"); + assert!(!wire.contains("SENTINEL"), "diff leaked a secret: {wire}"); + assert!( + !wire.contains("token="), + "diff leaked a query token: {wire}" + ); +} + +#[test] +fn no_sentinel_reaches_snapshot_debug_output() { + let rendered = format!("{:?}", seeded_with_sentinels()); + assert!(!rendered.contains("SENTINEL"), "Debug leaked: {rendered}"); + assert!(!rendered.contains("token="), "Debug leaked: {rendered}"); + // Large text is summarized rather than dumped. + assert!(!rendered.contains("You are a test agent.")); + // Non-secret leaves stay legible, or the log line is useless. + assert!(rendered.contains("goose")); +} + +#[test] +fn no_sentinel_reaches_the_owning_process_debug_output() { + // `ManagedAgentProcess` derives `Debug` and delegates to the snapshot's + // manual impl — this pins that the derive can never become the leak path. + #[cfg(unix)] + let program = "/usr/bin/true"; + #[cfg(windows)] + let program = "true"; + let child = std::process::Command::new(program) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn placeholder child"); + let process = crate::managed_agents::ManagedAgentProcess { + child, + log_path: std::path::PathBuf::new(), + spawn_config: seeded_with_sentinels(), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".to_string(), + #[cfg(windows)] + job: None, + }; + let rendered = format!("{process:?}"); + assert!( + !rendered.contains("SENTINEL"), + "process Debug leaked a secret" + ); + assert!( + !rendered.contains("token="), + "process Debug leaked a query token" + ); +} + +// ── B1: the eligible vector is the single source of the badge ──────────── + +fn eligible( + orphaned: bool, + stamped: &SpawnConfigSnapshot, + current: &SpawnConfigSnapshot, + stamped_availability: Option, + current_availability: Option, +) -> (bool, Vec) { + let entries = eligible_restart_diff( + orphaned, + Some(TrackedSpawnState { + stamped, + current, + stamped_availability: stamped_availability.as_ref(), + current_availability, + }), + ); + (!entries.is_empty(), entries) +} + +#[test] +fn no_drift_yields_no_badge_and_no_entries() { + let (needs_restart, entries) = eligible(false, &base(), &base(), None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn snapshot_drift_yields_a_badge_and_that_entry() { + let mut current = base(); + current.model = Some("claude-4".into()); + let (needs_restart, entries) = eligible(false, &base(), ¤t, None, None); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["model"]); +} + +#[test] +fn availability_drift_alone_yields_a_badge_and_its_synthetic_entry() { + let (needs_restart, entries) = eligible( + false, + &base(), + &base(), + Some(AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["adapter_availability"]); + assert_eq!( + change_at(&entries, "adapter_availability"), + &RestartChange::Value { + before: Value::String("available".into()), + after: Value::String("adapter_outdated".into()), + } + ); +} + +#[test] +fn orphan_with_snapshot_drift_yields_no_badge_and_no_entries() { + let mut current = base(); + current.model = Some("claude-4".into()); + let (needs_restart, entries) = eligible(true, &base(), ¤t, None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn orphan_with_availability_drift_yields_no_badge_and_no_entries() { + let (needs_restart, entries) = eligible( + true, + &base(), + &base(), + Some(AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn unstamped_availability_is_not_drift() { + // A runtime without a version gate stamps no availability; comparing that + // absence against a freshly cached value must not invent a badge. + let (needs_restart, entries) = eligible( + false, + &base(), + &base(), + None, + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn unstamped_agent_yields_no_badge_and_no_entries() { + // A `runtime_pid`-adopted process — and any agent this workspace tracks no + // live pair for — has no `ManagedAgentProcess`, so no spawn config was ever + // stamped. With nothing to compare against there is no drift to report, and + // the badge derives from that emptiness. Distinct from the case above, + // where a real pair IS tracked and only its availability stamp is absent. + for orphaned in [false, true] { + let entries = eligible_restart_diff(orphaned, None); + let needs_restart = !entries.is_empty(); + assert!( + entries.is_empty(), + "unstamped agent (orphaned={orphaned}) must report no changed fields" + ); + assert!( + !needs_restart, + "unstamped agent (orphaned={orphaned}) must not light the badge" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs similarity index 68% rename from desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs rename to desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index f4ad40481..d76605ecf 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -2,6 +2,19 @@ use super::*; use crate::managed_agents::types::RespondTo; use std::collections::BTreeMap; +/// Canonical projection of a prospective snapshot — the exact value the drift +/// comparison reads, so these tests assert on drift itself rather than on a +/// proxy for it. +fn snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, +) -> serde_json::Value { + prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical() +} + fn record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), @@ -86,22 +99,22 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { } #[test] -fn hash_is_deterministic() { +fn snapshot_is_deterministic() { let rec = record(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn materializing_runtime_keeps_hash_stable() { +fn materializing_runtime_keeps_snapshot_stable() { // Migration cutover invariant (Phase 1A): materializing the linked - // persona's runtime onto the record must NOT change the spawn hash — + // persona's runtime onto the record must NOT change the spawn snapshot — // otherwise every running persona-linked agent would show a spurious // restart badge right after migration. Pre-migration the command resolves // through the persona fallback; post-migration through record.runtime. - // Same persona, same runtime, same command → same hash. + // Same persona, same runtime, same command → equal snapshots. let personas = vec![persona("p1", Some("goose"), "Persona prompt.")]; let mut pre = record(); @@ -111,14 +124,14 @@ fn materializing_runtime_keeps_hash_stable() { post.runtime = Some("goose".into()); assert_eq!( - spawn_config_hash( + snapshot( &pre, &personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &post, &personas, &[], @@ -129,31 +142,31 @@ fn materializing_runtime_keeps_hash_stable() { } #[test] -fn record_env_var_edit_changes_hash() { +fn record_env_var_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited .env_vars .insert("SOME_KEY".into(), "some-value".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn record_prompt_edit_changes_hash() { +fn record_prompt_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited.system_prompt = Some("Edited prompt.".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn persona_runtime_edit_changes_hash() { +fn persona_runtime_edit_changes_snapshot() { // The harness command resolves live personas at spawn, so a persona // runtime change means a restart WOULD change what runs → badge trips. let mut rec = record(); @@ -161,13 +174,13 @@ fn persona_runtime_edit_changes_hash() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()) ); } #[test] -fn persona_prompt_edit_changes_hash() { +fn persona_prompt_edit_changes_snapshot() { // Start/restore re-snapshot the persona prompt onto the record right // before spawning, so a persona prompt edit DOES apply on a plain // restart → the badge must trip. @@ -176,13 +189,13 @@ fn persona_prompt_edit_changes_hash() { let before = [persona("pers", Some("goose"), "old prompt")]; let after = [persona("pers", Some("goose"), "new prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()) ); } #[test] -fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { +fn workspace_relay_change_trips_snapshot_even_for_stored_record_relay() { // The legacy per-record relay pin is ignored (#2122): every record spawns // against the active workspace relay, so a workspace relay change means a // restart would change what runs — pinned records included. @@ -192,13 +205,13 @@ fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { "fixture should carry a legacy pin" ); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://relay-b.example", &Default::default()) ); } #[test] -fn stored_record_relay_does_not_affect_hash() { +fn stored_record_relay_does_not_affect_snapshot() { // Editing the (ignored) stored pin must not badge a restart: what a // restart would run is identical either way. let mut a = record(); @@ -206,20 +219,20 @@ fn stored_record_relay_does_not_affect_hash() { a.relay_url = String::new(); b.relay_url = "wss://legacy-pin.example".into(); assert_eq!( - spawn_config_hash(&a, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&b, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&a, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&b, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn respond_to_allowlist_edit_changes_hash() { +fn respond_to_allowlist_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited.respond_to = RespondTo::Allowlist; edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } @@ -231,13 +244,13 @@ fn allowlist_ignored_when_mode_is_not_allowlist() { let mut edited = record(); edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn allowlist_normalization_equivalent_edits_do_not_change_hash() { +fn allowlist_normalization_equivalent_edits_do_not_change_snapshot() { // The env receives the normalized list (trim/lowercase/dedup), so edits // that normalize to the same value must not badge. let mut rec = record(); @@ -249,48 +262,48 @@ fn allowlist_normalization_equivalent_edits_do_not_change_hash() { "a".repeat(64), // duplicate ]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn allowlist_content_edit_still_changes_hash() { +fn allowlist_content_edit_still_changes_snapshot() { let mut rec = record(); rec.respond_to = RespondTo::Allowlist; rec.respond_to_allowlist = vec!["a".repeat(64)]; let mut edited = rec.clone(); edited.respond_to_allowlist = vec!["b".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn explicit_max_turn_duration_changes_hash_from_none() { +fn explicit_max_turn_duration_changes_snapshot_from_none() { let rec = record(); let mut edited = record(); edited.max_turn_duration_seconds = Some(7200); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn non_default_max_turn_duration_changes_hash() { +fn non_default_max_turn_duration_changes_snapshot() { let rec = record(); let mut edited = record(); edited.max_turn_duration_seconds = Some(42); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn non_spawn_bookkeeping_fields_do_not_change_hash() { +fn non_spawn_bookkeeping_fields_do_not_change_snapshot() { // updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn // inputs — routine record saves must not trip the badge. let rec = record(); @@ -300,17 +313,17 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() { edited.last_started_at = Some("later".into()); edited.last_exit_code = Some(0); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { - // B5 hash row 3: the prospective re-snapshot copies ONLY + // B5 drift row 3: the prospective re-snapshot copies ONLY // prompt/model/provider/env from the linked definition. An instance // whose owner hand-set respond_to/allowlist/parallelism must - // hash identically whether or not its definition carries a quad — + // snapshot identically whether or not its definition carries a quad — // activation of the definition-level defaults must never reach through // spawn and overwrite instance state. let quadless_definition = vec![persona("p1", Some("goose"), "Persona prompt.")]; @@ -326,44 +339,44 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { definition_with_quad[0].parallelism = Some(8); assert_eq!( - spawn_config_hash( + snapshot( &rec, &quadless_definition, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &rec, &definition_with_quad, &[], "wss://ws.example", &Default::default() ), - "definition quad must not leak into the spawn hash of an existing instance" + "definition quad must not leak into the spawn snapshot of an existing instance" ); } #[test] -fn empty_prompt_hashes_like_absent_prompt() { - // B5 hash row 2 foundation: Some("") and None spawn identically (env var - // absent either way), so they must hash equal — a backfilled prompt-less +fn empty_prompt_snapshots_like_absent_prompt() { + // B5 drift row 2 foundation: Some("") and None spawn identically (env var + // absent either way), so they must snapshot equal — a backfilled prompt-less // record re-snapshots to Some("") and must not trip the badge. let mut absent = record(); absent.system_prompt = None; let mut empty = record(); empty.system_prompt = Some(String::new()); assert_eq!( - spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&absent, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&empty, &[], &[], "wss://ws.example", &Default::default()), ); } -/// (a) A definition-runtime edit must change spawn_config_hash for a +/// (a) A definition-runtime edit must change the snapshot for a /// materialized, override-free record — the prospective re-snapshot now -/// copies the persona's runtime onto the record before hashing. +/// copies the persona's runtime onto the record before snapshotting. #[test] -fn definition_runtime_edit_changes_hash_for_materialized_record() { +fn definition_runtime_edit_changes_snapshot_for_materialized_record() { let mut rec = record(); rec.persona_id = Some("pers".into()); rec.runtime = Some("goose".into()); // materialized runtime on instance @@ -371,8 +384,8 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "definition runtime edit must badge a materialized, override-free instance" ); } @@ -389,8 +402,8 @@ fn known_runtime_pin_yields_to_definition_runtime_change() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "stale known-runtime pin must not shadow a definition runtime edit" ); } @@ -407,16 +420,16 @@ fn custom_command_override_beats_definition_runtime_change() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_eq!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "custom command override must win regardless of definition runtime change" ); } /// (d) When the linked definition is absent the prospective re-snapshot is -/// skipped entirely: the materialized runtime must still affect the hash. +/// skipped entirely: the materialized runtime must still reach the snapshot. #[test] -fn missing_definition_leaves_materialized_runtime_in_hash() { +fn missing_definition_leaves_materialized_runtime_in_snapshot() { let mut rec = record(); rec.persona_id = Some("missing".into()); rec.runtime = Some("goose".into()); // materialized runtime @@ -427,28 +440,28 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { no_runtime.runtime = None; assert_ne!( - spawn_config_hash( + snapshot( &rec, no_personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &no_runtime, no_personas, &[], "wss://ws.example", &Default::default() ), - "materialized runtime must still affect hash when definition is absent" + "materialized runtime must still reach the snapshot when definition is absent" ); } -// ── Global default trips hash for linked inherited agents ───────────────── +// ── Global default trips drift for linked inherited agents ─────────────── #[test] -fn global_model_change_trips_hash_for_linked_inherited_agent() { +fn global_model_change_trips_snapshot_for_linked_inherited_agent() { let mut rec = record(); rec.persona_id = Some("p1".into()); rec.model = Some("stale-record-model".into()); @@ -466,17 +479,17 @@ fn global_model_change_trips_hash_for_linked_inherited_agent() { ..Default::default() }; - let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); - let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a); + let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b); assert_ne!( - hash_a, hash_b, - "changing the global default must trip the hash for a linked inherited agent" + snapshot_a, snapshot_b, + "changing the global default must drift a linked inherited agent" ); } #[test] -fn global_model_change_trips_hash_without_model_env_var() { +fn global_model_change_trips_snapshot_without_model_env_var() { let mut rec = record(); rec.persona_id = Some("p1".into()); rec.agent_command = "some-harness-without-model-env".into(); @@ -497,26 +510,26 @@ fn global_model_change_trips_hash_without_model_env_var() { ..Default::default() }; - let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); - let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a); + let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b); assert_ne!( - hash_a, hash_b, - "global model change must trip hash even without a model_env_var runtime" + snapshot_a, snapshot_b, + "global model change must drift even without a model_env_var runtime" ); } #[test] -fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { +fn linked_instance_stale_prompt_bytes_are_inert_at_snapshot_time() { // Regression for the split-resolve defect: prompt used to be read from // the record's own (possibly Phase-A-snapshot-stale) bytes while // model/provider were resolved live from the definition. A definition // edit landing between a caller's snapshot apply and spawn could hand a - // fresh model/provider to a stale prompt, and the hash (which already + // fresh model/provider to a stale prompt, and the drift check (which already // resolved model/provider live) would silently agree with a spawn that // wrote the stale prompt. Now both come from one `resolve_effective_config` // call, so a record whose own `system_prompt` bytes disagree with the - // live definition must hash exactly as if the record carried the + // live definition must snapshot exactly as if the record carried the // definition's prompt verbatim — the record's prompt bytes are inert for // a linked instance. let mut rec = record(); @@ -529,26 +542,26 @@ fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { let personas = [persona("p1", Some("goose"), "live prompt")]; assert_eq!( - spawn_config_hash( + snapshot( &rec, &personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &matching_bytes, &personas, &[], "wss://ws.example", &Default::default() ), - "record's own system_prompt bytes must not affect the hash of a linked instance" + "record's own system_prompt bytes must not affect the snapshot of a linked instance" ); } #[test] -fn display_name_edit_changes_hash() { +fn display_name_edit_changes_snapshot() { // The spawn writes BUZZ_ACP_SESSION_TITLE from display_name-or-name, so a // rename must trip the badge: the running process keeps the old title // until it restarts, and the operator has to be told that. @@ -556,32 +569,32 @@ fn display_name_edit_changes_hash() { let mut renamed = record(); renamed.display_name = Some("Fizz".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), "a display-name rename changes the spawned session title and must badge" ); } #[test] -fn name_edit_changes_hash_when_display_name_is_absent() { +fn name_edit_changes_snapshot_when_display_name_is_absent() { // With no display_name the title falls back to the unique handle, so the - // handle is what the env write carries and what must be hashed. + // handle is what the env write carries and what must be snapshotted. let rec = record(); let mut renamed = record(); renamed.name = "agent-2".into(); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), - "the fallback title source must reach the hash too" + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), + "the fallback title source must reach the snapshot too" ); } #[test] -fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() { +fn display_name_edit_does_not_change_snapshot_under_an_explicit_title_override() { // User env is written AFTER the Buzz-set title (last-wins), so an explicit // BUZZ_ACP_SESSION_TITLE is what the child actually runs with. Renaming the // record changes nothing about the spawned process, so badging it would be - // a false restart prompt. The override itself still reaches the hash + // a false restart prompt. The override itself still reaches the snapshot // through the effective env. let mut rec = record(); rec.env_vars @@ -589,14 +602,14 @@ fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() { let mut renamed = rec.clone(); renamed.display_name = Some("Fizz".into()); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), "a rename shadowed by an explicit title override must not badge" ); } #[test] -fn title_override_edit_changes_hash() { +fn title_override_edit_changes_snapshot() { // Counterpart to the test above: the override is not inert — editing it // changes what the child runs with and must badge. let mut rec = record(); @@ -607,8 +620,8 @@ fn title_override_edit_changes_hash() { .env_vars .insert("BUZZ_ACP_SESSION_TITLE".into(), "Other Title".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()), "editing an explicit title override must badge" ); } @@ -616,7 +629,7 @@ fn title_override_edit_changes_hash() { #[test] fn linked_instance_prompt_model_provider_resolve_from_one_call() { // The prompt for a linked instance must track the definition, exactly - // like model/provider — a definition prompt edit trips the hash even + // like model/provider — a definition prompt edit drifts the snapshot even // though the record's own (stale) system_prompt bytes are unchanged. let mut rec = record(); rec.persona_id = Some("p1".into()); @@ -626,25 +639,25 @@ fn linked_instance_prompt_model_provider_resolve_from_one_call() { let after = [persona("p1", Some("goose"), "new definition prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "linked instance prompt must resolve from the live definition, not stale record bytes" ); } -// ── I2: definition args and env reach spawn_config_hash ────────────────────── +// ── I2: definition args and env reach the snapshot ─────────────────────────── // // These tests prove that editing a custom harness definition's args or env -// changes spawn_config_hash, which trips the "restart required" badge. -// They would fail if spawn_config_hash used only record.agent_args without +// change the snapshot, which trips the "restart required" badge. +// They would fail if the snapshot used only record.agent_args without // falling back to definition args, or if resolve_effective_agent_env did not // include definition env. /// When a record has no instance args but the definition has default args, -/// changing the definition args changes the spawn hash. This would fail if -/// spawn_config_hash used only record.agent_args. +/// changing the definition args changes the snapshot. This would fail if +/// the snapshot used only record.agent_args. #[test] -fn spawn_hash_changes_when_definition_default_args_change() { +fn spawn_snapshot_changes_when_definition_default_args_change() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -652,8 +665,8 @@ fn spawn_hash_changes_when_definition_default_args_change() { use tempfile::tempdir; // The loaded-harness registry is process-global: a parallel test re-warming - // it between the two hash computations makes both resolve to no-definition - // and h1 == h2 (observed on Windows CI). + // it between the two snapshots makes both resolve to no-definition + // and s1 == s2 (observed on Windows CI). let _lock = registry_test_lock(); let dir = tempdir().unwrap(); @@ -669,7 +682,7 @@ fn spawn_hash_changes_when_definition_default_args_change() { r.runtime = Some("my-def".into()); r.agent_args = vec![]; // no instance args → definition args are used - let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); // Update to v2 args and re-warm (simulating save + transactional refresh). fs::write( @@ -679,18 +692,18 @@ fn spawn_hash_changes_when_definition_default_args_change() { .unwrap(); warm_harness_registry_from_dir(Some(dir.path())); - let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); assert_ne!( - h1, h2, - "changing definition default args must change the spawn hash" + s1, s2, + "changing definition default args must change the snapshot" ); } -/// When a definition has env vars, adding them changes the spawn hash. This +/// When a definition has env vars, adding them changes the snapshot. This /// proves resolve_effective_agent_env includes definition env in the layering. #[test] -fn spawn_hash_changes_when_definition_env_changes() { +fn spawn_snapshot_changes_when_definition_env_changes() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -712,7 +725,7 @@ fn spawn_hash_changes_when_definition_env_changes() { let mut r = record(); r.runtime = Some("env-def".into()); - let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); // Update to include env and re-warm. fs::write( @@ -722,16 +735,16 @@ fn spawn_hash_changes_when_definition_env_changes() { .unwrap(); warm_harness_registry_from_dir(Some(dir.path())); - let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); - assert_ne!(h1, h2, "adding definition env must change the spawn hash"); + assert_ne!(s1, s2, "adding definition env must change the snapshot"); } /// Instance-level args win over definition default args (non-empty instance -/// args must NOT be overridden by the definition). The hash must match a record +/// args must NOT be overridden by the definition). The snapshot must match a record /// that has the same effective args from either source. #[test] -fn spawn_hash_instance_args_win_over_definition_args() { +fn spawn_snapshot_instance_args_win_over_definition_args() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -756,12 +769,12 @@ fn spawn_hash_instance_args_win_over_definition_args() { r_no_instance.runtime = Some("arg-def".into()); r_no_instance.agent_args = vec![]; - let h_instance = spawn_config_hash(&r_instance, &[], &[], "ws://relay", &Default::default()); - let h_no_instance = - spawn_config_hash(&r_no_instance, &[], &[], "ws://relay", &Default::default()); + let snapshot_instance = snapshot(&r_instance, &[], &[], "ws://relay", &Default::default()); + let snapshot_no_instance = + snapshot(&r_no_instance, &[], &[], "ws://relay", &Default::default()); assert_ne!( - h_instance, h_no_instance, - "instance args and definition args must produce different hashes" + snapshot_instance, snapshot_no_instance, + "instance args and definition args must produce different snapshots" ); } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 255c1aae3..c5bb6173d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -462,13 +462,12 @@ pub struct RelayMeshConfig { pub struct ManagedAgentProcess { pub child: Child, pub log_path: PathBuf, - /// Digest of the effective spawn config at launch (see - /// `spawn_hash::spawn_config_hash`). Runtime-only — never persisted. The - /// summary builder recomputes the hash from current disk state and flags - /// `needs_restart` on mismatch. Agents adopted via a persisted - /// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn - /// config is unknown and the badge stays off. - pub spawn_config_hash: u64, + /// The effective spawn config this process was launched with (see + /// `spawn_snapshot::SpawnConfigSnapshot`). Runtime-only — never persisted. + /// The summary builder recomputes a prospective snapshot and reports + /// differing fields via `ManagedAgentSummary::restart_diff`. Agents + /// adopted via `runtime_pid` have none; their config is unknown. + pub spawn_config: super::spawn_snapshot::SpawnConfigSnapshot, /// Whether this process was spawned in setup-listener mode (i.e. /// `BUZZ_ACP_SETUP_PAYLOAD` was set at launch because the agent was /// `NotReady`). Runtime-only — never persisted. Used by @@ -541,13 +540,14 @@ pub struct ManagedAgentSummary { /// `OrphanedInstance` arm via `require_resolved`) — so the UI /// should surface that it's stuck, not merely stale. pub persona_orphaned: bool, - /// `true` when the running process was spawned with a config that no - /// longer matches what a spawn would use today — a plain restart would - /// change what runs. Complements `persona_out_of_date`: the badge means - /// "a restart would change what runs"; out-of-date means "a respawn - /// would." Always `false` for stopped agents and for processes adopted - /// via a persisted `runtime_pid` (their spawn config is unknown). + /// `true` when the running process's spawn config no longer matches + /// what a spawn would use today. Derived from `restart_diff` — lit + /// exactly when there is something to show. Always `false` for stopped, + /// orphaned, or `runtime_pid`-adopted agents. pub needs_restart: bool, + /// Fields that drifted since launch, redacted for display. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub restart_diff: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, pub backend: BackendKind, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 96ed55606..1db7b9b52 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -694,3 +694,93 @@ fn mint_rejects_out_of_range_input_parallelism() { "input-branch error must not blame the definition: {err}" ); } + +// ── Restart-diff wire shape ───────────────────────────────────────────────── + +fn summary_fixture( + restart_diff: Vec, +) -> super::ManagedAgentSummary { + super::ManagedAgentSummary { + pubkey: "aa".repeat(32), + name: "test".into(), + persona_id: None, + runtime: None, + team_id: None, + relay_url: String::new(), + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: Vec::new(), + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + avatar_url: None, + model: None, + model_source: None, + provider: None, + persona_out_of_date: false, + persona_orphaned: false, + // Both fields derive from one vector in `build_managed_agent_summary`; + // the fixture reproduces that rule rather than letting them disagree. + needs_restart: !restart_diff.is_empty(), + restart_diff, + env_vars: Default::default(), + backend: super::BackendKind::Local, + backend_agent_id: None, + status: "running".into(), + pid: Some(4242), + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + start_on_app_launch: false, + auto_restart_on_config_change: false, + log_path: String::new(), + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: Vec::new(), + } +} + +#[test] +fn summary_without_drift_omits_restart_diff_from_the_wire() { + // An adopted `runtime_pid`-only process is never stamped, so its summary + // carries an empty vector. `skip_serializing_if` must then drop the key + // entirely — the frontend normalizes omission to `[]`, and emitting an + // empty array on every stopped agent would bloat every list response. + let wire = serde_json::to_value(summary_fixture(Vec::new())).expect("summary serializes"); + assert_eq!(wire.get("needs_restart"), Some(&serde_json::json!(false))); + assert!( + wire.get("restart_diff").is_none(), + "empty restart_diff must be omitted, got: {wire}" + ); +} + +#[test] +fn summary_with_drift_serializes_restart_diff_entries() { + // The other side of the same rule: a present entry must reach the wire + // under its snake_case key with the tagged change payload intact. + let wire = serde_json::to_value(summary_fixture(vec![ + crate::managed_agents::spawn_snapshot::RestartDiffEntry { + field: "model".into(), + change: crate::managed_agents::spawn_snapshot::diff::RestartChange::Value { + before: serde_json::json!("gpt-5"), + after: serde_json::json!("claude-4"), + }, + }, + ])) + .expect("summary serializes"); + assert_eq!(wire.get("needs_restart"), Some(&serde_json::json!(true))); + assert_eq!( + wire.get("restart_diff"), + Some(&serde_json::json!([{ + "field": "model", + "change": { "kind": "value", "before": "gpt-5", "after": "claude-4" }, + }])) + ); +} diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index cd62f63bb..74cef7ffe 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -26,7 +26,7 @@ use crate::managed_agents::{ /// `unwrap_or_default`, env COPIED so later instances inherit a working /// config, quad copied to the definition defaults) and the record gains /// `persona_source_version` = the new definition's content hash, so -/// neither `spawn_config_hash` nor the drift badge moves. +/// neither the spawn-config snapshot nor the drift badge moves. /// /// The manufactured definition's slug is the agent's pubkey: 64-hex passes /// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 5d52d5667..d277a2aa5 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -1,5 +1,5 @@ use super::backfill_standalone_agents_in_dir; -use crate::managed_agents::spawn_hash::spawn_config_hash; +use crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot; use crate::managed_agents::{AgentDefinition, ManagedAgentRecord}; use crate::migration::test_support::{read_agents_json, write_agents_json}; use std::path::Path; @@ -116,11 +116,11 @@ fn backfilled_definition_carries_prompt_present_even_if_empty() { } #[test] -fn backfill_of_promptless_record_keeps_spawn_hash_stable() { - // B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill +fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { + // B5 drift row 2: pre-backfill the record snapshots prompt None; post-backfill // the prospective re-snapshot pulls Some("") from the manufactured // definition. The spawn layer treats an empty prompt as no prompt (env - // absent either way), so the hash must not move — otherwise every + // absent either way), so the snapshot must not move — otherwise every // prompt-less standalone agent lights the restart badge on upgrade. let dir = tempfile::tempdir().unwrap(); let pubkey = "c".repeat(64); @@ -131,7 +131,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { let pre_records = load_typed(dir.path()); let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); - let hash_before = spawn_config_hash( + let before = prospective_spawn_config_snapshot( pre_instance, &[], &[], @@ -147,7 +147,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { .iter() .filter_map(|r| r.to_definition_view()) .collect(); - let hash_after = spawn_config_hash( + let after = prospective_spawn_config_snapshot( post_instance, &personas, &[], @@ -156,15 +156,16 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { ); assert_eq!( - hash_before, hash_after, + before.canonical(), + after.canonical(), "backfill must not flip the restart badge for prompt-less agents" ); } #[test] -fn backfill_of_prompted_record_keeps_spawn_hash_stable() { +fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { // The general no-behavior-change rail: a standalone agent WITH config - // must also hash identically across backfill (the definition snapshots + // must also snapshot identically across backfill (the definition snapshots // the record's own values, so the re-snapshot writes back what is // already there). let dir = tempfile::tempdir().unwrap(); @@ -180,7 +181,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { let pre_records = load_typed(dir.path()); let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); - let hash_before = spawn_config_hash( + let before = prospective_spawn_config_snapshot( pre_instance, &[], &[], @@ -196,7 +197,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { .iter() .filter_map(|r| r.to_definition_view()) .collect(); - let hash_after = spawn_config_hash( + let after = prospective_spawn_config_snapshot( post_instance, &personas, &[], @@ -204,7 +205,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { &Default::default(), ); - assert_eq!(hash_before, hash_after); + assert_eq!(before.canonical(), after.canonical()); } #[test] diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs index 5930920dd..6ca23200e 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -15,8 +15,8 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records}; /// persona (unified agent model, Phase 1A). After this, spawn resolution reads /// the record's own runtime (`record_agent_command` step 2) instead of the /// live persona — same effective command by construction, so the spawn-config -/// hash is unchanged and no running agent shows a spurious restart badge (see -/// `spawn_hash::tests::materializing_runtime_keeps_hash_stable`). +/// snapshot is unchanged and no running agent shows a spurious restart badge +/// (see `spawn_snapshot::tests::materializing_runtime_keeps_snapshot_stable`). /// /// Idempotent: records that already carry `runtime` are untouched, as are /// records with no linked persona or a persona without a runtime (both keep diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index efd88f3ca..c5b774310 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -19,6 +19,7 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a .store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app.state::().prevent_sleep); + crate::huddle::chat_tts::shutdown(); app.state::() .shutdown_all(); if let Err(error) = shutdown_managed_agents(app) { diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 736dad1f6..8370e8efd 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -17,12 +17,6 @@ type AppHuddleShellProps = { onShowHuddleInMainApp: (ephemeralChannelId: string) => void; onViewHuddleChannel: (ephemeralChannelId: string) => void; onVisibilityChange: (visible: boolean) => void; - /** - * Terminal substrate layer. Rendered behind the app surface (which carries - * z-10) so the ⌘J handoff can reveal it by fading the surface above. Not - * mounted in the dedicated Huddle room window. - */ - terminal?: React.ReactNode; }; export function AppHuddleShell({ @@ -37,7 +31,6 @@ export function AppHuddleShell({ onShowHuddleInMainApp, onViewHuddleChannel, onVisibilityChange, - terminal, }: AppHuddleShellProps) { return ( - {isRoom ? null : terminal}
setIsCreateChannelOpen(true), @@ -775,7 +773,6 @@ export function AppShell() { onShowHuddleInMainApp={showHuddleInMainApp} onViewHuddleChannel={viewHuddleChannel} onVisibilityChange={handleHuddleVisibilityChange} - terminal={} > {hasCommunityRail && !isHuddleRoom ? ( } > @@ -955,8 +954,7 @@ export function AppShell() { ) : null}
)} - - + + + + + + ); +} diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx index 4ab2ea1df..37be3448e 100644 --- a/desktop/src/app/AppShellChannelSurface.tsx +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -11,6 +11,7 @@ type AppShellChannelSurfaceProps = { isHuddleRoom: boolean; isHuddleRoomStarting: boolean; mainInsetRef: React.RefObject; + terminal?: React.ReactNode; }; export function AppShellChannelSurface({ @@ -18,6 +19,7 @@ export function AppShellChannelSurface({ isHuddleRoom, isHuddleRoomStarting, mainInsetRef, + terminal, }: AppShellChannelSurfaceProps) { return ( @@ -34,7 +36,7 @@ export function AppShellChannelSurface({ style={chromeCssVarDefaults as React.CSSProperties} > {isHuddleRoom && !isHuddleRoomStarting ? : null} - + {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 4976fc2ed..b7912c98b 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -23,8 +23,10 @@ export function GradientLayer() { export function ContentSurface({ children, unframed = false, + terminal, }: { children: ReactNode; + terminal?: ReactNode; /** Used by dedicated huddle windows, which should not resemble app cards. */ unframed?: boolean; }) { @@ -38,7 +40,12 @@ export function ContentSurface({ data-buzz-content-surface data-buzz-content-unframed={unframed ? true : undefined} > - {children} +
+ {children} +
+
+ {terminal} +
); } diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 3ba76dece..fb3a9a2f7 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -4,6 +4,7 @@ import { shouldBounceForChannelNotification, toSearchHit, } from "@/app/AppShell.helpers"; +import { useChatSpeech } from "@/features/huddle/lib/useChatSpeech"; import { getThreadReference } from "@/features/messages/lib/threading"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; @@ -42,9 +43,14 @@ export function useAppShellDesktopNotifications({ ) => Promise; pubkey?: string; }) { + const speakChatMessage = useChatSpeech(); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!enabled) return; + // Speech is independent of the dock/alert rules below — an agent reply + // is read aloud even when it wouldn't raise a desktop notification. + speakChatMessage(event); if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; void requestDockBounce(); @@ -54,6 +60,7 @@ export function useAppShellDesktopNotifications({ const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { if (!enabled) return; + speakChatMessage(event); if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.dm diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 02c97bac5..2d56d24cb 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; +import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { desktopBadgeEnabled: boolean; @@ -16,6 +17,10 @@ export function useAppShellLifecycleEffects({ unreadChannelIds, unreadChannelNotificationCount, }: AppShellLifecycleEffectsOptions) { + // Event-driven reconnect: network online / focus / visibility short-circuit + // the backoff timer when the relay session is degraded (CMD+R gap G1). + useRelayResumeTriggers(); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index 6f13a84ae..b0668616a 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -77,7 +77,12 @@ export function AgentIdentityCard({ {modelLabel} ) : null} - {statusBadge} + {/* pointer-events-auto: the overlay button above has pointer-events-none + on this container, but the status badge itself (a sibling of the button + in z-order) needs hover so the restart diff tooltip can fire. */} + {statusBadge ? ( +
{statusBadge}
+ ) : null} ); diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 606d2b788..62a4169fc 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,11 +1,6 @@ import * as React from "react"; -import { - AlertTriangle, - ChevronDown, - ChevronRight, - RefreshCw, -} from "lucide-react"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -27,6 +22,7 @@ import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastE import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { PubKey } from "@/shared/ui/PubKey"; import { SubsectionLabel } from "@/shared/ui/PageHeader"; +import { RestartDiffBadge } from "./RestartDiffBadge"; export function ManagedAgentRow({ agent, @@ -100,7 +96,7 @@ export function ManagedAgentRow({ "overflow-hidden transition-colors", isLogSelected ? "bg-primary/5" : "hover:bg-muted/20", )} - data-testid={`managed-agent-${agent.pubkey}`} + data-testid={`managed-agent-row-${agent.pubkey}`} >
{isLocal ? ( @@ -158,7 +154,16 @@ export function ManagedAgentRow({
)} + {/* B4: restart badge is a sibling of the expansion button — never + inside it. TooltipTrigger renders as a (non-interactive), + so no nested interactive elements are introduced here. */}
+ {agent.needsRestart ? ( + + ) : null} + ) : null; + const channelActions = activeChannel ? ( showJoinButton ? ( + +
+ +
Expires after @@ -94,7 +214,7 @@ export function InviteLinkSection({ aria-label="Choose invite expiry" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-ttl-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -129,7 +249,7 @@ export function InviteLinkSection({ aria-label="Choose maximum invite uses" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-max-uses-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -159,28 +279,6 @@ export function InviteLinkSection({
- -
- -
); } diff --git a/desktop/src/features/huddle/components/AgentSpeechStopButton.tsx b/desktop/src/features/huddle/components/AgentSpeechStopButton.tsx new file mode 100644 index 000000000..8502313ba --- /dev/null +++ b/desktop/src/features/huddle/components/AgentSpeechStopButton.tsx @@ -0,0 +1,59 @@ +import { Square, Volume2 } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + getAgentSpeaking, + stopAgentSpeech, + subscribeAgentSpeech, +} from "../lib/agentSpeechState"; + +/** + * Floating "Stop" control shown while an agent is reading a message aloud. + * + * Muting agent speech entirely (the huddle speaker toggle, or the setting) + * silences every future reply too. This stops only what is playing now, and + * `Escape` does the same thing without aiming at the button. + */ +export function AgentSpeechStopButton() { + const speaking = React.useSyncExternalStore( + subscribeAgentSpeech, + getAgentSpeaking, + ); + + React.useEffect(() => { + if (!speaking) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + void stopAgentSpeech(); + }; + // Capture phase: dialogs and menus stop Escape before it reaches window. + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [speaking]); + + if (!speaking) return null; + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index b7b3eb434..e875dd04e 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -27,6 +27,7 @@ import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; +import { stopAgentSpeech } from "../lib/agentSpeechState"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; @@ -646,7 +647,12 @@ export function HuddleBar({
{ + // Unmuting means "I want to talk now" — cut the agent off so it + // is not still speaking over you, same as push-to-talk does. + if (isMuted) void stopAgentSpeech(); + toggleMute(); + }} isPttMode={isPttMode} pttActive={pttActive} micConnected={hasAvailableMic} diff --git a/desktop/src/features/huddle/lib/agentSpeechState.ts b/desktop/src/features/huddle/lib/agentSpeechState.ts new file mode 100644 index 000000000..0e80fcbe1 --- /dev/null +++ b/desktop/src/features/huddle/lib/agentSpeechState.ts @@ -0,0 +1,94 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * Tracks whether an agent is speaking right now, so the UI can offer a way to + * stop it. + * + * The backend owns the truth (`is_agent_speaking`), but there is no event for + * it — playback ends inside the TTS worker thread. Rather than add an event + * channel for a flag the UI only cares about while audio is playing, this + * polls, and only between the moment a message is handed to the backend and + * the moment playback drains. While nothing is speaking, nothing runs. + */ + +const POLL_INTERVAL_MS = 400; +/** + * Synthesis of the first sentence happens before `is_agent_speaking` flips + * true, so a poll that gave up on the first "false" would stop tracking before + * playback even began. Keep polling until it has stayed false this long. + */ +const START_GRACE_MS = 8000; + +let speaking = false; +let pollId: number | null = null; +let notSpeakingSince: number | null = null; +const listeners = new Set<() => void>(); + +function setSpeaking(next: boolean) { + if (speaking === next) return; + speaking = next; + for (const listener of listeners) listener(); +} + +function stopPolling() { + if (pollId !== null) { + window.clearInterval(pollId); + pollId = null; + } + notSpeakingSince = null; +} + +async function poll() { + let active = false; + try { + active = await invokeTauri("is_agent_speaking"); + } catch (error) { + console.warn("[agent-speech] status check failed:", error); + setSpeaking(false); + stopPolling(); + return; + } + setSpeaking(active); + if (active) { + notSpeakingSince = null; + return; + } + notSpeakingSince ??= Date.now(); + if (Date.now() - notSpeakingSince >= START_GRACE_MS) stopPolling(); +} + +/** Begin tracking playback after handing a message to the backend. */ +export function trackAgentSpeech() { + notSpeakingSince = null; + if (pollId !== null) return; + pollId = window.setInterval(() => void poll(), POLL_INTERVAL_MS); + void poll(); +} + +/** Silence the current utterance and drop anything still queued behind it. */ +export async function stopAgentSpeech() { + // Optimistic: the button should disappear on click, not a poll tick later. + setSpeaking(false); + try { + await invokeTauri("stop_agent_speech"); + } catch (error) { + console.warn("[agent-speech] stop failed:", error); + } +} + +export function subscribeAgentSpeech(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getAgentSpeaking() { + return speaking; +} + +/** Reset on community switch — see `resetCommunityState()`. */ +export function resetAgentSpeechState() { + stopPolling(); + setSpeaking(false); +} diff --git a/desktop/src/features/huddle/lib/stripForSpeech.ts b/desktop/src/features/huddle/lib/stripForSpeech.ts new file mode 100644 index 000000000..7ae710569 --- /dev/null +++ b/desktop/src/features/huddle/lib/stripForSpeech.ts @@ -0,0 +1,36 @@ +/** Longest utterance we will read aloud; agents can post very long replies. */ +const MAX_SPOKEN_CHARS = 600; + +/** + * Reduce a chat message to the part worth hearing. + * + * Read verbatim, an agent reply is unpleasant to listen to: fenced code, + * URLs, and markdown punctuation all get spelled out. This strips the parts + * that only make sense on screen and keeps the prose, deterministically — no + * model call, so it stays instant and cannot invent or drop content. + */ +export function stripForSpeech(content: string): string { + return ( + content + // Fenced code blocks: unreadable aloud, and often the bulk of a reply. + .replace(/```[\s\S]*?```/g, " ") + .replace(/~~~[\s\S]*?~~~/g, " ") + // Inline code keeps its text — often a name worth hearing. + .replace(/`([^`]*)`/g, "$1") + // Images drop entirely; links keep their label, not the URL. + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + // Bare URLs read as noise. + .replace(/https?:\/\/\S+/g, " ") + // Nostr mention tokens (nostr:npub1…) are not words. + .replace(/\bnostr:[a-z0-9]+\b/gi, " ") + // Emphasis, headings, blockquotes, and list bullets are screen-only. + .replace(/[*_~]{1,3}/g, "") + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + .replace(/^\s{0,3}>\s?/gm, "") + .replace(/^\s{0,3}[-*+]\s+/gm, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_SPOKEN_CHARS) + ); +} diff --git a/desktop/src/features/huddle/lib/useChatSpeech.ts b/desktop/src/features/huddle/lib/useChatSpeech.ts new file mode 100644 index 000000000..2eccbb569 --- /dev/null +++ b/desktop/src/features/huddle/lib/useChatSpeech.ts @@ -0,0 +1,41 @@ +import * as React from "react"; + +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { invokeTauri } from "@/shared/api/tauri"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { RelayEvent } from "@/shared/api/types"; +import { trackAgentSpeech } from "./agentSpeechState"; +import { stripForSpeech } from "./stripForSpeech"; + +/** + * Speaks agent replies that arrive in ordinary channels. + * + * The huddle TTS path (`useTtsSubscription`) only covers ephemeral huddle + * channels and requires a live call. This is the timeline equivalent: an agent + * posts a normal message and the desktop reads it aloud. The backend owns the + * enable/disable check, so this only decides *what* is eligible to be spoken. + */ +export function useChatSpeech(): (event: RelayEvent) => void { + const agentPubkeys = useKnownAgentPubkeys(); + + return React.useCallback( + (event: RelayEvent) => { + if (!agentPubkeys.has(normalizePubkey(event.pubkey))) return; + const text = stripForSpeech(event.content); + if (!text) return; + // `speakerPubkey` selects that agent's own voice — without it every + // agent in the channel is read out by the same one. + void invokeTauri("speak_chat_message", { + text, + speakerPubkey: event.pubkey, + }) + .then(() => { + trackAgentSpeech(); + }) + .catch((error) => { + console.warn("[chat-tts] speak failed:", error); + }); + }, + [agentPubkeys], + ); +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index fb01783bf..e1f2e7775 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -18,6 +18,7 @@ import { import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { TimelineMessage } from "@/features/messages/types"; +import { useRecordRecentThread } from "@/features/threads/useRecordRecentThread"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout"; @@ -249,6 +250,13 @@ export function MessageThreadPanel({ >(null); const isOverlay = useIsThreadPanelOverlay(); const threadHeadId = threadHead?.id ?? null; + useRecordRecentThread({ + currentPubkey, + threadRootId: threadHeadId, + channelId, + channelName, + threadHeadBody: threadHead?.body, + }); useEscapeKey( onClose, !isHuddleTranscript && (isOverlay || isSinglePanelView || isFocusMode), diff --git a/desktop/src/features/messages/useAutoOpenAgentThread.ts b/desktop/src/features/messages/useAutoOpenAgentThread.ts new file mode 100644 index 000000000..c0d0b68d5 --- /dev/null +++ b/desktop/src/features/messages/useAutoOpenAgentThread.ts @@ -0,0 +1,104 @@ +import * as React from "react"; + +import { useIsHuddleTranscript } from "@/features/channels/ui/useHuddleChannelMessages"; +import { useChannelMessagesQuery } from "@/features/messages/hooks"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel } from "@/shared/api/types"; + +/** + * Pops the thread panel open when someone else's reply lands in a thread of the + * channel you are already looking at. + * + * Agents answer in threads, so without this a reply from an agent shows up only + * as a "1 reply" affordance under the message you sent — easy to miss while you + * are sitting in the channel waiting for it. + * + * Deliberately conservative, because yanking a panel open is disruptive: + * - Never fires for messages that already existed when you arrived. Each + * channel gets a high-water mark on first load, so history stays quiet. + * - Never steals focus from a thread you already have open. + * - Ignores your own replies — those already open the panel optimistically. + * - Ignores broadcast replies, which are not real thread participation. + * - Stays out of Huddle transcripts, which run their own thread isolation. + * + * Sources its own messages and identity instead of taking them as arguments: + * the query is keyed by channel, so it shares ChannelScreen's cache entry + * rather than issuing a second fetch. + */ +export function useAutoOpenAgentThread(args: { + activeChannel: Channel | null; + openThreadHeadId: string | null; + setOpenThreadHeadId: (threadHeadId: string | null) => void; +}): void { + const { activeChannel, openThreadHeadId, setOpenThreadHeadId } = args; + const activeChannelId = activeChannel?.id ?? null; + const messages = useChannelMessagesQuery(activeChannel).data; + const currentPubkey = useIdentityQuery().data?.pubkey; + const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + + // Newest createdAt already accounted for, per channel. A missing entry means + // this channel has not been seeded yet — that batch only sets the mark. + const highWaterMarkRef = React.useRef<{ + channelId: string | null; + createdAt: number; + } | null>(null); + + // Read through refs so the effect keys off `messages` alone. Otherwise + // opening a thread would re-run it and immediately re-open the next one. + const openThreadHeadIdRef = React.useRef(openThreadHeadId); + openThreadHeadIdRef.current = openThreadHeadId; + const setOpenThreadHeadIdRef = React.useRef(setOpenThreadHeadId); + setOpenThreadHeadIdRef.current = setOpenThreadHeadId; + + React.useEffect(() => { + if (!activeChannelId || !messages) return; + + const mark = highWaterMarkRef.current; + const newestCreatedAt = messages.reduce( + (newest, message) => Math.max(newest, message.created_at), + 0, + ); + + // First sight of this channel: remember where we came in, open nothing. + if (!mark || mark.channelId !== activeChannelId) { + highWaterMarkRef.current = { + channelId: activeChannelId, + createdAt: newestCreatedAt, + }; + return; + } + + const since = mark.createdAt; + highWaterMarkRef.current = { + channelId: activeChannelId, + createdAt: Math.max(since, newestCreatedAt), + }; + + if (isHuddleTranscript) return; + // A thread is already open — leave the viewer where they are. + if (openThreadHeadIdRef.current) return; + + let latest: { createdAt: number; rootId: string } | null = null; + for (const message of messages) { + if (message.created_at <= since) continue; + if (message.pending) continue; + if (currentPubkey && message.pubkey === currentPubkey) continue; + if (isBroadcastReply(message.tags)) continue; + + const { parentId, rootId } = getThreadReference(message.tags); + if (parentId === null || rootId === null) continue; + + if (!latest || message.created_at > latest.createdAt) { + latest = { createdAt: message.created_at, rootId }; + } + } + + if (latest) { + setOpenThreadHeadIdRef.current(latest.rootId); + } + }, [activeChannelId, currentPubkey, isHuddleTranscript, messages]); +} diff --git a/desktop/src/features/notifications/hooks.test.mjs b/desktop/src/features/notifications/hooks.test.mjs index e5b90f3ac..26fc4fedb 100644 --- a/desktop/src/features/notifications/hooks.test.mjs +++ b/desktop/src/features/notifications/hooks.test.mjs @@ -37,7 +37,7 @@ const homeFeed = (feed) => ({ meta: { since: 0, total: 0, generatedAt: 0 }, }); -test("home badge items include locally unread activity and agent rows", () => { +test("home badge excludes thread activity already shown in a channel preview", () => { const items = buildHomeBadgeFeedItems( homeFeed({ mentions: [feedItem("mention", "mention")], @@ -51,7 +51,12 @@ test("home badge items include locally unread activity and agent rows", () => { feedItem("read-agent", "agent_activity"), ], }), - [feedItem("thread-activity")], + [ + { + ...feedItem("thread-activity"), + tags: ROOT_TAGS, + }, + ], new Set(["locally-unread-activity", "locally-unread-agent"]), ); @@ -60,7 +65,6 @@ test("home badge items include locally unread activity and agent rows", () => { [ "mention", "needs-action", - "thread-activity", "locally-unread-activity", "locally-unread-agent", ], diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts index deac9b73a..b98db88e0 100644 --- a/desktop/src/features/notifications/lib/homeBadge.ts +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -24,9 +24,19 @@ export function buildHomeBadgeFeedItems( extraInboxItems: readonly FeedItem[], localUnreadFeedIds: ReadonlySet, ): FeedItem[] { + // Thread activity is surfaced directly on its channel's hover preview. It + // should not also inflate the Inbox numeral, which is reserved for the + // Inbox's own high-priority activity. + const nonThreadExtraInboxItems = extraInboxItems.filter( + (item) => !isThreadReply(item.tags), + ); const items = feed - ? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems] - : [...extraInboxItems]; + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...nonThreadExtraInboxItems, + ] + : [...nonThreadExtraInboxItems]; if (feed && localUnreadFeedIds.size > 0) { items.push( diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 04546804e..7a456fb25 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -5,7 +5,6 @@ import type { } from "@tanstack/react-query"; import * as React from "react"; import { - keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -41,6 +40,10 @@ import { shouldFetchAvatar, resolveAvatarDataUrl, } from "@/features/profile/lib/selfProfileStorage"; +import { + resolveUserLabelPlaceholderData, + writeCachedUserLabels, +} from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; @@ -317,6 +320,8 @@ export function useUsersBatchQuery( }, ) { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? ""; const normalizedPubkeys = [ ...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())), ] @@ -352,6 +357,9 @@ export function useUsersBatchQuery( } if (toFetch.length > 0) { const fresh = await getUsersBatch(toFetch); + if (relayUrl) { + writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); + } for (const pubkey of toFetch) { const summary = fresh.profiles[pubkey] ?? null; queryClient.setQueryData( @@ -367,7 +375,12 @@ export function useUsersBatchQuery( // Loading older messages grows the pubkey set, which changes this query's // key entirely. Without this, already-resolved authors would flash back // to their raw pubkey while the larger batch refetches. - placeholderData: keepPreviousData, + placeholderData: (previousData) => + resolveUserLabelPlaceholderData( + previousData, + relayUrl, + normalizedPubkeys, + ), staleTime: 60_000, gcTime: 5 * 60 * 1_000, }); @@ -375,6 +388,9 @@ export function useUsersBatchQuery( // Seed individual "user-profile" cache entries so avatar clicks are instant // cache hits instead of fresh network requests. React.useEffect(() => { + // Persisted labels are intentionally presentation-only. Wait for a relay + // result before seeding profile-detail caches that also carry ownership. + if (query.dataUpdatedAt === 0) return; const profiles = query.data?.profiles; if (!profiles) return; for (const [pubkey, summary] of Object.entries(profiles)) { @@ -391,7 +407,7 @@ export function useUsersBatchQuery( }, ); } - }, [query.data, queryClient]); + }, [query.data, query.dataUpdatedAt, queryClient]); return query; } diff --git a/desktop/src/features/profile/lib/userLabelStorage.test.mjs b/desktop/src/features/profile/lib/userLabelStorage.test.mjs new file mode 100644 index 000000000..c791b1f73 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadSubject() { + try { + return await import("./userLabelStorage.ts"); + } catch { + return {}; + } +} + +function installLocalStorage() { + const values = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }, + }; + globalThis.localStorage = globalThis.window.localStorage; + return values; +} + +test("reads cached labels as safe stale profile summaries", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + updatedAt: 100, + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + nip05Handle: "alice@example.com", + updatedAt: 100, + }, + }, + }), + ); + + assert.deepEqual( + subject.readCachedUserLabels("WSS://Relay.Example/", ["ABCDEF", "missing"]), + { + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: "alice@example.com", + ownerPubkey: null, + }, + }, + missing: [], + }, + ); +}); + +test("keeps previous full profiles ahead of persisted label placeholders", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.resolveUserLabelPlaceholderData, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + profiles: { + abcdef: { + displayName: "Cached Alice", + name: "alice", + nip05Handle: null, + updatedAt: 100, + }, + }, + }), + ); + const previous = { + profiles: { + abcdef: { + displayName: "Fresh Alice", + name: "alice", + avatarUrl: "https://relay.example/alice.png", + nip05Handle: null, + ownerPubkey: "owner", + }, + }, + missing: [], + }; + + assert.equal( + subject.resolveUserLabelPlaceholderData(previous, "wss://relay.example", [ + "abcdef", + ]), + previous, + ); +}); + +test("writes merge with existing labels and remain bounded", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + existing: { + displayName: "Existing", + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels( + "wss://relay.example", + Object.fromEntries( + Array.from({ length: 1_005 }, (_, index) => [ + `pubkey-${index}`, + { + displayName: `Person ${index}`, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + ]), + ), + ); + + const stored = JSON.parse( + window.localStorage.getItem( + subject.userLabelCacheKey("wss://relay.example"), + ), + ); + assert.equal(Object.keys(stored.profiles).length, 1_000); + assert.equal(stored.version, 1); + assert.equal(stored.updatedAt, undefined); +}); + +test("removes a stale label when the fresh profile clears all names", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: null, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes stale labels for profiles the relay reports missing", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", {}, ["ABCDEF"]); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes only the selected relay cache", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.removeUserLabelCacheForRelay, "function"); + installLocalStorage(); + const first = subject.userLabelCacheKey("wss://one.example"); + const second = subject.userLabelCacheKey("wss://two.example"); + window.localStorage.setItem(first, "{}"); + window.localStorage.setItem(second, "{}"); + + subject.removeUserLabelCacheForRelay("wss://one.example"); + + assert.equal(window.localStorage.getItem(first), null); + assert.equal(window.localStorage.getItem(second), "{}"); +}); + +test("ignores malformed cache payloads", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ version: 1, profiles: { abc: { displayName: 42 } } }), + ); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abc"]), + undefined, + ); +}); diff --git a/desktop/src/features/profile/lib/userLabelStorage.ts b/desktop/src/features/profile/lib/userLabelStorage.ts new file mode 100644 index 000000000..b28c17da9 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.ts @@ -0,0 +1,176 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { + UserProfileSummary, + UsersBatchResponse, +} from "@/shared/api/types"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_KEY_PREFIX = "buzz-user-labels.v1"; +const MAX_CACHED_LABELS = 1_000; + +type CachedUserLabel = { + displayName: string | null; + name: string | null; + nip05Handle: string | null; + updatedAt: number; +}; + +type UserLabelCache = { + version: 1; + profiles: Record; +}; + +export function userLabelCacheKey(relayUrl: string): string { + return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}`; +} + +function nullableString(value: unknown): string | null | undefined { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : undefined; +} + +function parseCachedUserLabel(value: unknown): CachedUserLabel | null { + if (typeof value !== "object" || value === null) return null; + const raw = value as Record; + const displayName = nullableString(raw.displayName); + const name = nullableString(raw.name); + const nip05Handle = nullableString(raw.nip05Handle); + if ( + displayName === undefined || + name === undefined || + nip05Handle === undefined + ) { + return null; + } + if (![displayName, name, nip05Handle].some((label) => label?.trim())) { + return null; + } + return { + displayName, + name, + nip05Handle, + updatedAt: + typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0, + }; +} + +function readCache(relayUrl: string): UserLabelCache | null { + try { + const raw = window.localStorage.getItem(userLabelCacheKey(relayUrl)); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) return null; + const payload = parsed as Record; + if ( + payload.version !== 1 || + typeof payload.profiles !== "object" || + payload.profiles === null + ) { + return null; + } + + const profiles: Record = {}; + for (const [pubkey, value] of Object.entries( + payload.profiles as Record, + )) { + const label = parseCachedUserLabel(value); + if (label) profiles[pubkey.toLowerCase()] = label; + } + return { + version: 1, + profiles, + }; + } catch { + return null; + } +} + +export function readCachedUserLabels( + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + const cache = readCache(relayUrl); + if (!cache) return undefined; + + const profiles: UsersBatchResponse["profiles"] = {}; + for (const pubkey of pubkeys) { + const normalizedPubkey = pubkey.toLowerCase(); + const cached = cache.profiles[normalizedPubkey]; + if (!cached) continue; + profiles[normalizedPubkey] = { + displayName: cached.displayName, + name: cached.name, + avatarUrl: null, + nip05Handle: cached.nip05Handle, + ownerPubkey: null, + }; + } + + return Object.keys(profiles).length > 0 + ? { profiles, missing: [] } + : undefined; +} + +export function resolveUserLabelPlaceholderData( + previousData: UsersBatchResponse | undefined, + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + return ( + previousData ?? + (relayUrl ? readCachedUserLabels(relayUrl, pubkeys) : undefined) + ); +} + +export function writeCachedUserLabels( + relayUrl: string, + profiles: Record, + missing: string[] = [], +): void { + try { + const now = Date.now(); + const merged = { ...(readCache(relayUrl)?.profiles ?? {}) }; + for (const [pubkey, profile] of Object.entries(profiles)) { + const label = parseCachedUserLabel({ + displayName: profile.displayName, + name: profile.name, + nip05Handle: profile.nip05Handle, + updatedAt: now, + }); + const normalizedPubkey = pubkey.toLowerCase(); + if (label) { + merged[normalizedPubkey] = label; + } else { + delete merged[normalizedPubkey]; + } + } + for (const pubkey of missing) { + delete merged[pubkey.toLowerCase()]; + } + + const boundedProfiles = Object.fromEntries( + Object.entries(merged) + .sort(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, MAX_CACHED_LABELS), + ); + setLocalStorageItemWithRecovery( + userLabelCacheKey(relayUrl), + JSON.stringify({ + version: 1, + profiles: boundedProfiles, + } satisfies UserLabelCache), + ); + } catch { + // Storage access failures are non-fatal. + } +} + +export function removeUserLabelCacheForRelay(relayUrl: string): void { + try { + window.localStorage.removeItem(userLabelCacheKey(relayUrl)); + } catch { + // Storage access failures are non-fatal. + } +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 057376a38..22647eb28 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -10,6 +10,7 @@ import { import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; +import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge"; import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { getPresenceLabel } from "@/features/presence/lib/presence"; @@ -386,6 +387,18 @@ export function ProfileSummaryView({ /> ) : null} + {/* Tab-independent restart badge — visible on every tab so the user + sees it on a plain Info-tab open, not only on the Runtime tab. + Fixes the side-panel badge inconsistency (info-tab default = badge invisible + before this change). */} + {managedAgent?.needsRestart ? ( + + ) : null} + {showTabSection ? (
{showTabBar ? ( @@ -420,6 +433,7 @@ export function ProfileSummaryView({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsTrailing} needsRestart={managedAgent?.needsRestart ?? false} + restartDiff={managedAgent?.restartDiff ?? []} onOpenDiagnostics={onOpenDiagnostics} onOpenInstructions={onOpenInstructions} runtimeConfigurationFields={runtimeConfigurationFields} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 0fe4e1958..be6d5add4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -9,7 +9,12 @@ import { Wrench, } from "lucide-react"; -import type { ManagedAgent } from "@/shared/api/types"; +import type { ManagedAgent, RestartDiffEntry } from "@/shared/api/types"; +import { + AUTO_RESTART_OFF_BLURB, + AUTO_RESTART_ON_BLURB, + RestartDiffList, +} from "@/features/agents/ui/RestartDiffBadge"; import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; import { @@ -809,6 +814,7 @@ export function ProfileRuntimeTabContent({ diagnosticsFields, diagnosticsSummary, needsRestart = false, + restartDiff = [], onOpenDiagnostics, onOpenInstructions, runtimeConfigurationFields, @@ -823,6 +829,8 @@ export function ProfileRuntimeTabContent({ diagnosticsSummary: React.ReactNode; /** True when the running agent's config has drifted from what it was spawned with. */ needsRestart?: boolean; + /** The full itemised diff — shown uncapped in the Runtime banner. */ + restartDiff?: RestartDiffEntry[]; onOpenDiagnostics: () => void; onOpenInstructions: () => void; runtimeConfigurationFields: ProfileField[]; @@ -844,7 +852,8 @@ export function ProfileRuntimeTabContent({ statusDiagnosticsFields.length === 0 && detailDiagnosticsFields.length === 0 && !showDiagnosticsIngress && - !showInstructionBlock + !showInstructionBlock && + !needsRestart ) { return null; } @@ -863,9 +872,12 @@ export function ProfileRuntimeTabContent({

{autoRestartEnabled - ? "Configuration changed since this agent started. Buzz can restart it automatically after ~3 minutes idle, or stop and respawn it to apply now." - : "Configuration changed since this agent started. Automatic restart is off for this agent \u2014 stop and respawn it to apply the changes."} + ? AUTO_RESTART_ON_BLURB + : AUTO_RESTART_OFF_BLURB}

+ {/* Full uncapped diff list — Runtime banner is the only surface + where all entries show without truncation. */} +
) : null} diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs new file mode 100644 index 000000000..d548b7e50 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; +import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts"; + +test("keeps every unread channel navigable while adding working activity", () => { + const activity = getOffscreenActivityChannelIds({ + activeWorkingByChannelId: new Map([["working", {}]]), + previewActivityChannelIds: new Set(["preview"]), + unreadChannelIds: new Set(["dm", "forum", "stream"]), + }); + + assert.deepEqual([...activity.messageChannelIds].sort(), [ + "dm", + "forum", + "preview", + "stream", + ]); + assert.deepEqual([...activity.channelIds].sort(), [ + "dm", + "forum", + "preview", + "stream", + "working", + ]); +}); + +test("uses an activity-neutral overflow label when work contributes", () => { + assert.equal( + getSidebarActivityOverflowLabel({ activityCount: 2, messageCount: 1 }), + "2 new activity", + ); + assert.equal( + getSidebarActivityOverflowLabel({ activityCount: 1, messageCount: 1 }), + undefined, + ); +}); diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts new file mode 100644 index 000000000..aef953eff --- /dev/null +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +type OffscreenActivityChannelIds = { + messageChannelIds: ReadonlySet; + channelIds: ReadonlySet; +}; + +export function getOffscreenActivityChannelIds({ + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, +}: { + activeWorkingByChannelId: ReadonlyMap; + previewActivityChannelIds: ReadonlySet; + unreadChannelIds: ReadonlySet; +}): OffscreenActivityChannelIds { + // Every unread row must remain navigable, including top-level stream and + // forum unreads that do not have thread-preview activity. + const messageChannelIds = new Set([ + ...unreadChannelIds, + ...previewActivityChannelIds, + ]); + + return { + messageChannelIds, + channelIds: new Set([ + ...messageChannelIds, + ...activeWorkingByChannelId.keys(), + ]), + }; +} + +export function useOffscreenActivityChannelIds(args: { + activeWorkingByChannelId: ReadonlyMap; + previewActivityChannelIds: ReadonlySet; + unreadChannelIds: ReadonlySet; +}) { + const { + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, + } = args; + + return React.useMemo( + () => + getOffscreenActivityChannelIds({ + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, + }), + [activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds], + ); +} diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts new file mode 100644 index 000000000..c194ed9cf --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts @@ -0,0 +1,45 @@ +import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds"; +import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; + +type ActivityOptions = Parameters[0]; +type ScrollRef = Parameters[0]["scrollRef"]; + +export function getSidebarActivityOverflowLabel({ + activityCount, + messageCount, +}: { + activityCount: number; + messageCount: number; +}) { + return activityCount === messageCount + ? undefined + : `${activityCount} new activity`; +} + +export function useSidebarActivityOverflow({ + scrollRef, + ...activityOptions +}: ActivityOptions & { scrollRef: ScrollRef }) { + const { channelIds, messageChannelIds } = + useOffscreenActivityChannelIds(activityOptions); + const activityOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: channelIds, + }); + const messageOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: messageChannelIds, + }); + + return { + ...activityOverflow, + unreadAboveLabel: getSidebarActivityOverflowLabel({ + activityCount: activityOverflow.unreadAboveCount, + messageCount: messageOverflow.unreadAboveCount, + }), + unreadBelowLabel: getSidebarActivityOverflowLabel({ + activityCount: activityOverflow.unreadBelowCount, + messageCount: messageOverflow.unreadBelowCount, + }), + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index d4ca4f286..d9bb32301 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -22,7 +22,7 @@ import { import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; -import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; +import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow"; import { CreateSectionDialog, DeleteSectionAlertDialog, @@ -37,6 +37,7 @@ import { } from "@/features/sidebar/ui/AppSidebarPinnedHeader"; import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton"; import { SidebarSection } from "@/features/sidebar/ui/SidebarSection"; +import { RecentThreadsSection } from "@/features/sidebar/ui/RecentThreadsSection"; import { ChannelGroupSection, CustomChannelSection, @@ -105,6 +106,7 @@ type AppSidebarProps = { | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; communities: Community[]; onAddCommunity: (community: Community) => void; onAddCommunityOpenChange?: (open: boolean) => void; @@ -146,11 +148,7 @@ type AppSidebarProps = { onSelectHome: () => void; onSelectChannel: (channelId: string) => void; onOpenSearchResult: (hit: SearchHit) => void; - /** - * Full channel set used for global search. Unlike `channels` (which is - * scoped to the viewer's joined sidebar list), this includes open channels - * the viewer hasn't joined, so search can surface them. - */ + /** Full channel set for global search — unlike `channels` (scoped to the viewer's joined sidebar list), this includes open channels the viewer hasn't joined. */ searchChannels: Channel[]; searchFocusRequest: number; onSelectSettings: (section?: SettingsSection) => void; @@ -194,6 +192,7 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, + previewActivityChannelIds, communities, onAddCommunity, onAddCommunityOpenChange, @@ -250,6 +249,8 @@ export function AppSidebar({ const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); + // biome-ignore format: keep compact to stay within file size limit + const { scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -308,9 +309,8 @@ export function AppSidebar({ // Allow the create-channel dialog to be opened from outside (e.g. the // ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift. - // When the external flag flips on, open the "stream" create dialog; the - // close direction is reported back via `onCreateChannelOpenChange` in the - // dialog's `onOpenChange` below. + // When the external flag flips on, open the "stream" create dialog; the close + // direction is reported back via `onCreateChannelOpenChange` in `onOpenChange`. React.useEffect(() => { if (isCreateChannelOpenProp) { openCreateDialog("stream"); @@ -503,13 +503,6 @@ export function AppSidebar({ profile?.displayName?.trim() || fallbackDisplayName?.trim() || "Current identity"; - const { - scrollToNextAbove, - scrollToNextBelow, - unreadAboveCount, - unreadBelowCount, - } = useUnreadOverflow({ scrollRef, unreadChannelIds }); - const isCreatingAny = createDialogKind === "stream" ? isCreatingChannel @@ -591,6 +584,7 @@ export function AppSidebar({ {unreadAboveCount > 0 ? ( + - - Inbox + + + Inbox + {homeBadgeCount > 0 ? ( - - Agents + + + Agents + diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1e50ef72e..1c86ac4c3 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -21,6 +21,10 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; +const ACTIVITY_POPOVER_MOTION_STYLE = { + "--tw-enter-scale": "1", + "--tw-exit-scale": "1", +} as React.CSSProperties; function buildChannelActivityFeed(items: FeedItem[]): HomeFeedResponse { return { @@ -365,19 +369,17 @@ export function ChannelActivityPopover({ return ( - - {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while this wrapper supplies the popover anchor box. */} -
setOpen(false)} - onFocus={openImmediately} - onMouseEnter={openWithDelay} - onMouseLeave={closeWithDelay} - > - {children} -
-
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while the wrapper keeps the preview interactive. */} +
setOpen(false)} + onFocus={openImmediately} + onMouseEnter={openWithDelay} + onMouseLeave={closeWithDelay} + > + {children} +
event.preventDefault()} side="right" - sideOffset={8} + sideOffset={0} + style={ACTIVITY_POPOVER_MOTION_STYLE} >
void; position: "top" | "bottom"; testId: string; @@ -23,7 +25,8 @@ export function MoreUnreadButton({ > diff --git a/desktop/src/features/sidebar/ui/RecentThreadsSection.tsx b/desktop/src/features/sidebar/ui/RecentThreadsSection.tsx new file mode 100644 index 000000000..4208e2c98 --- /dev/null +++ b/desktop/src/features/sidebar/ui/RecentThreadsSection.tsx @@ -0,0 +1,91 @@ +import { MessageSquare, X } from "lucide-react"; + +import { useRecentThreadsSidebar } from "@/features/sidebar/ui/useRecentThreadsSidebar"; +import type { SearchHit } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/shared/ui/sidebar"; + +const ROW_ACTION_VISIBILITY_CLASS = + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 md:opacity-0"; + +/** + * Sidebar list of threads the viewer has recently opened, so a thread + * lost behind other conversations can be found again without hunting + * through the channel it originated in. Self-contained: reads the + * recent-threads store directly and reuses the existing search-hit + * navigation path to reopen a panel. + */ +export function RecentThreadsSection({ + currentPubkey, + onOpenSearchResult, +}: { + currentPubkey: string | undefined; + onOpenSearchResult: (hit: SearchHit) => void; +}) { + const { recentThreads, onOpenThread, onRemoveThread } = + useRecentThreadsSidebar(currentPubkey, onOpenSearchResult); + + if (recentThreads.length === 0) return null; + + return ( + + Active Threads + + + {recentThreads.map((thread) => ( + + onOpenThread(thread)} + tooltip={thread.preview || thread.channelName} + type="button" + > + + + #{thread.channelName} + {thread.preview ? ( + + {" — "} + {thread.preview} + + ) : null} + + + onRemoveThread(thread)} /> + + ))} + + + + ); +} + +function RemoveThreadButton({ onRemove }: { onRemove: () => void }) { + return ( + + ); +} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 797e907ba..56e12146b 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -214,10 +214,12 @@ function DmChannelIcon({ function SidebarChannelIcon({ channel, + className, dmParticipants, presenceStatus, }: { channel: Channel; + className?: string; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; }) { @@ -238,14 +240,14 @@ function SidebarChannelIcon({ } if (channel.visibility === "private") { - return ; + return ; } if (channel.channelType === "forum") { - return ; + return ; } - return ; + return ; } export function ChannelMenuButton({ @@ -288,21 +290,24 @@ export function ChannelMenuButton({ (hasSidebarUnreadProjections ? unreadThreadChannelIds.has(channel.id) : hasUnread); + const inactiveContentOpacity = cn( + !isActive && !hasTopLevelUnread && !isMuted && "opacity-80", + !isActive && + isMuted && + !hasTopLevelUnread && + !hasThreadUnread && + "opacity-50 dark:opacity-45", + ); const button = ( - + {resolvedLabel} {ephemeralDisplay ? ( diff --git a/desktop/src/features/sidebar/ui/useRecentThreadsSidebar.ts b/desktop/src/features/sidebar/ui/useRecentThreadsSidebar.ts new file mode 100644 index 000000000..6ae29c9c9 --- /dev/null +++ b/desktop/src/features/sidebar/ui/useRecentThreadsSidebar.ts @@ -0,0 +1,52 @@ +import * as React from "react"; + +import { + removeRecentThread, + useRecentThreads, + type RecentThread, +} from "@/features/threads/recentThreadsStore"; +import type { SearchHit } from "@/shared/api/types"; + +/** + * Sidebar-facing wiring for the "Active Threads" list: reads the recorded + * threads for the current identity and adapts click/remove actions to the + * existing search-hit navigation path (`onOpenSearchResult` already knows + * how to open a specific thread panel by rootId). + */ +export function useRecentThreadsSidebar( + currentPubkey: string | undefined, + onOpenSearchResult: (hit: SearchHit) => void, +): { + recentThreads: RecentThread[]; + onOpenThread: (thread: RecentThread) => void; + onRemoveThread: (thread: RecentThread) => void; +} { + const recentThreads = useRecentThreads(currentPubkey); + + const onOpenThread = React.useCallback( + (thread: RecentThread) => { + onOpenSearchResult({ + eventId: thread.rootId, + content: thread.preview, + kind: 9, + pubkey: "", + channelId: thread.channelId, + channelName: thread.channelName, + createdAt: Math.floor(thread.lastActivityAt / 1_000), + score: 0, + threadRootId: thread.rootId, + }); + }, + [onOpenSearchResult], + ); + + const onRemoveThread = React.useCallback( + (thread: RecentThread) => { + if (!currentPubkey) return; + removeRecentThread(currentPubkey, thread.rootId); + }, + [currentPubkey], + ); + + return { recentThreads, onOpenThread, onRemoveThread }; +} diff --git a/desktop/src/features/terminal/TerminalBootstrap.test.mjs b/desktop/src/features/terminal/TerminalBootstrap.test.mjs index 34eb11ce7..5a849a817 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.test.mjs +++ b/desktop/src/features/terminal/TerminalBootstrap.test.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; +import { after, afterEach, before, beforeEach, test } from "node:test"; import { JSDOM } from "jsdom"; +import { setTerminalPanelMode } from "./terminalPanelStore.ts"; // `pretendToBeVisual` is what gives jsdom requestAnimationFrame. The banner's // animation loop needs it; without it the loop silently never runs and every @@ -18,6 +19,8 @@ let resizeCallback; let canvasWidth = 840; let attachResolver = null; let deferResizes = false; +let deferClose = false; +let closeResolver = null; const pendingResizes = []; before(async () => { @@ -33,7 +36,10 @@ before(async () => { dom.window.localStorage.setItem("buzz-follow-system", "false"); dom.window.isTauri = true; dom.window.matchMedia = () => ({ - matches: false, + // This suite exercises bootstrap/IPC behavior, not banner motion. Keeping + // animation disabled avoids competing perpetual rAF loops under the full + // parallel test runner; motion itself is covered by TerminalSubstrate. + matches: true, addEventListener() {}, removeEventListener() {}, }); @@ -79,9 +85,12 @@ before(async () => { calls.push({ command, args }); if (command === "terminal_attach") { channel = args.onFrame; + const sessionNumber = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; const response = { - sessionId: "session-1", - subscriptionId: "subscription-1", + sessionId: `session-${sessionNumber}`, + subscriptionId: `subscription-${sessionNumber}`, viewport: { columns: 100, generation: 0, screenLines: 24 }, }; return attachResolver @@ -101,6 +110,11 @@ before(async () => { pendingResizes.push(() => resolve(value)); }); } + if (command === "terminal_close" && deferClose) { + return new Promise((resolve) => { + closeResolver = resolve; + }); + } return Promise.resolve(); }, transformCallback(callback) { @@ -115,11 +129,17 @@ before(async () => { }); after(() => dom.window.close()); -afterEach(() => { +beforeEach(() => setTerminalPanelMode("docked")); +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + setTerminalPanelMode("closed"); calls.length = 0; canvasWidth = 840; attachResolver = null; deferResizes = false; + deferClose = false; + closeResolver = null; pendingResizes.length = 0; }); @@ -172,6 +192,9 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame threadId: "thread-1", }); + const sessionNumber = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; const frameMessage = { type: "frame", payload: { @@ -181,7 +204,7 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame full: true, rows: [], sequence: 7, - subscriptionId: "subscription-1", + subscriptionId: `subscription-${sessionNumber}`, viewport: { columns: 100, generation: 0, screenLines: 24 }, }, }; @@ -195,8 +218,8 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame calls.find(({ command }) => command === "terminal_ack").args, { sequence: 7, - sessionId: "session-1", - subscriptionId: "subscription-1", + sessionId: `session-${sessionNumber}`, + subscriptionId: `subscription-${sessionNumber}`, }, ); @@ -300,11 +323,6 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a await Promise.resolve(); }); const substrate = view.container.querySelector(".buzz-terminal-substrate"); - const chord = { bubbles: true, code: "KeyJ", metaKey: true }; - act(() => { - window.dispatchEvent(new KeyboardEvent("keydown", chord)); - window.dispatchEvent(new KeyboardEvent("keyup", chord)); - }); await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "terminal"), ); @@ -323,7 +341,124 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a view.unmount(); }); -test("a successful close removes the tab even if the exit event is lost", async () => { +test("restoring a channel resizes its PTY to the current dock viewport", async () => { + const { createElement } = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + const props = (channelId, channelName) => ({ + channelId, + channelName, + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }); + const tree = (channelId, channelName) => + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, props(channelId, channelName)), + ); + const view = render(tree("channel-a", "alpha")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_attach" && + args.request.channelId === "channel-a", + ), + ), + ); + + view.rerender(tree("channel-b", "beta")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_attach" && + args.request.channelId === "channel-b", + ), + ), + ); + canvasWidth = 1_680; + await act(async () => resizeCallback()); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_resize" && + args.sessionId === "session-2" && + args.columns === 200, + ), + ), + ); + + view.rerender(tree("channel-a", "alpha")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_resize" && + args.sessionId === "session-1" && + args.columns === 200, + ), + ), + ); + view.unmount(); +}); + +test("closing a tab while attach is pending closes the eventual session", async () => { + const { createElement } = await import("react"); + const { act, fireEvent, render, waitFor } = await import( + "@testing-library/react" + ); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + attachResolver = () => {}; + const view = render( + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: "channel-1", + channelName: "general", + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => assert.equal(typeof attachResolver, "function")); + await waitFor(() => + assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })), + ); + + await act(async () => { + fireEvent.click(view.getByLabelText("Close SHELL")); + setTerminalPanelMode("closed"); + }); + await waitFor(() => assert.equal(view.queryByRole("tab"), null)); + assert.equal( + calls.some(({ command }) => command === "terminal_close"), + false, + "a not-yet-attached session cannot be closed by backend id", + ); + + await act(async () => attachResolver()); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_close" && args.sessionId === "session-1", + ), + ), + ); + view.unmount(); +}); + +test("closing removes the tab before native shutdown resolves", async () => { const { createElement } = await import("react"); const { fireEvent, render, waitFor } = await import("@testing-library/react"); const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); @@ -349,14 +484,19 @@ test("a successful close removes the tab even if the exit event is lost", async await waitFor(() => assert.ok(calls.some(({ command }) => command === "terminal_attach")), ); - await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ }))); + await waitFor(() => + assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })), + ); + deferClose = true; fireEvent.click(view.getByLabelText("Close SHELL")); await waitFor(() => assert.ok(calls.some(({ command }) => command === "terminal_close")), ); await waitFor(() => assert.equal(view.queryByRole("tab"), null)); + assert.equal(typeof closeResolver, "function"); + closeResolver(); view.unmount(); }); @@ -423,3 +563,43 @@ test("wheel deltas reach terminal_scroll with the DOM sign intact", async () => view.unmount(); }); + +test("a non-channel route closes the panel and ignores the terminal shortcut", async () => { + const { createElement } = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + const { getTerminalPanelSnapshotForTests } = await import( + "./terminalPanelStore.ts" + ); + + setTerminalPanelMode("docked"); + const view = render( + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: null, + channelName: null, + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"), + ); + + const chord = { + bubbles: true, + code: "KeyJ", + metaKey: true, + }; + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", chord)); + window.dispatchEvent(new KeyboardEvent("keyup", chord)); + }); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); + view.unmount(); +}); diff --git a/desktop/src/features/terminal/TerminalBootstrap.tsx b/desktop/src/features/terminal/TerminalBootstrap.tsx index 20905e9fb..5ed653c6a 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.tsx +++ b/desktop/src/features/terminal/TerminalBootstrap.tsx @@ -11,6 +11,12 @@ import { TerminalSubstrate, type TerminalViewportSize, } from "./TerminalSubstrate"; +import { + setTerminalPanelMode, + setTerminalSessionChannels, + toggleTerminalPanel, + useTerminalPanel, +} from "./terminalPanelStore"; type TerminalContext = { channelId: string; @@ -27,6 +33,7 @@ type Session = { frame: TerminalFrameMessage | undefined; title: string; closing: boolean; + context: TerminalContext; }; const INITIAL_SIZE: TerminalViewportSize = { @@ -68,13 +75,98 @@ export function TerminalBootstrap({ const mountedRef = React.useRef(true); const sizeRef = React.useRef(INITIAL_SIZE); const resizeChainRef = React.useRef(Promise.resolve()); + const connectionSizesRef = React.useRef( + new WeakMap(), + ); + const closedSessionKeysRef = React.useRef(new Set()); const [sessions, setSessions] = React.useState([]); const [activeKey, setActiveKey] = React.useState(null); const [available, setAvailable] = React.useState(() => isTauri()); + const panel = useTerminalPanel(); + const [renderedMode, setRenderedMode] = React.useState< + "docked" | "maximized" + >(panel.mode === "maximized" ? "maximized" : "docked"); + const [panelVisible, setPanelVisible] = React.useState( + panel.mode !== "closed", + ); + const [panelMounted, setPanelMounted] = React.useState( + panel.mode !== "closed", + ); + const [splashPending, setSplashPending] = React.useState(true); + const [viewportReportingEnabled, setViewportReportingEnabled] = + React.useState(panel.mode !== "closed"); + const previousPanelModeRef = React.useRef(panel.mode); const acknowledgedSequenceRef = React.useRef(new Map()); const sessionsRef = React.useRef(sessions); sessionsRef.current = sessions; + React.useEffect(() => { + const previousMode = previousPanelModeRef.current; + if (previousMode === panel.mode) return; + previousPanelModeRef.current = panel.mode; + setViewportReportingEnabled(false); + + let firstFrame = 0; + let secondFrame = 0; + let timeout = 0; + if (panel.mode !== "closed") { + setRenderedMode(panel.mode); + setPanelMounted(true); + if (previousMode === "closed") { + // Give the collapsed substrate a painted frame before expanding it. + // A single rAF can still be batched into the mount commit by React. + setPanelVisible(false); + firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => + setPanelVisible(true), + ); + }); + } else { + setPanelVisible(true); + } + // Resizing the PTY through every animation frame causes shell reflow and + // leaves transient filler rows in the scrollback. Publish only the final + // settled viewport. + timeout = window.setTimeout(() => setViewportReportingEnabled(true), 200); + } else { + setPanelVisible(false); + timeout = window.setTimeout(() => setPanelMounted(false), 180); + } + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + window.clearTimeout(timeout); + }; + }, [panel.mode]); + + React.useEffect(() => { + if (!context && panel.mode !== "closed") setTerminalPanelMode("closed"); + }, [context, panel.mode]); + + React.useEffect(() => { + const toggle = (event: KeyboardEvent) => { + if ( + !context || + panel.mode !== "closed" || + event.code !== "KeyJ" || + (!event.metaKey && !event.ctrlKey) || + event.altKey || + event.shiftKey || + event.isComposing + ) + return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (event.type === "keyup") toggleTerminalPanel(); + }; + window.addEventListener("keydown", toggle, true); + window.addEventListener("keyup", toggle, true); + return () => { + window.removeEventListener("keydown", toggle, true); + window.removeEventListener("keyup", toggle, true); + }; + }, [context, panel.mode]); + const fail = React.useCallback((error: unknown) => { report(error); setAvailable(false); @@ -102,6 +194,7 @@ export function TerminalBootstrap({ frame: undefined, title: "SHELL", closing: false, + context: spawnContext, }; setSessions((current) => [...current, initial]); setActiveKey(key); @@ -144,6 +237,8 @@ export function TerminalBootstrap({ ) .then((connection) => { if (!mountedRef.current) return connection.detach(); + if (closedSessionKeysRef.current.delete(key)) return connection.close(); + connectionSizesRef.current.set(connection, size); update((session) => ({ ...session, connection })); if (sizeRef.current !== size) { const currentSize = sizeRef.current; @@ -155,20 +250,49 @@ export function TerminalBootstrap({ currentSize.pixelWidth, currentSize.pixelHeight, ); + connectionSizesRef.current.set(connection, currentSize); await connection.viewportReady(viewport); }) .catch(fail); } }) .catch((error) => { + if (closedSessionKeysRef.current.delete(key)) return; removeSession(key); fail(error); }); }, [available, fail, removeSession]); + const contextChannelId = context?.channelId ?? null; + const channelSessions = React.useMemo( + () => + contextChannelId + ? sessions.filter( + (session) => session.context.channelId === contextChannelId, + ) + : [], + [contextChannelId, sessions], + ); + + React.useEffect(() => { + setTerminalSessionChannels( + sessions.map((session) => session.context.channelId), + ); + }, [sessions]); + React.useEffect(() => { - if (available && context && sessions.length === 0) createSession(); - }, [available, context, createSession, sessions.length]); + if (panel.mode === "closed" || !available || !context) return; + if (channelSessions.length === 0) createSession(); + else if (!channelSessions.some((session) => session.key === activeKey)) + setActiveKey(channelSessions.at(-1)?.key ?? null); + }, [ + activeKey, + available, + channelSessions, + context, + createSession, + panel.mode, + ]); React.useEffect(() => { mountedRef.current = true; @@ -180,8 +304,34 @@ export function TerminalBootstrap({ }; }, []); - const active = sessions.find((session) => session.key === activeKey) ?? null; + const active = + channelSessions.find((session) => session.key === activeKey) ?? + channelSessions.at(-1) ?? + null; + + React.useEffect(() => { + const connection = active?.connection; + if (!connection) return; + const size = sizeRef.current; + if (connectionSizesRef.current.get(connection) === size) return; + resizeChainRef.current = resizeChainRef.current + .then(async () => { + const viewport = await connection.resize( + size.columns, + size.rows, + size.pixelWidth, + size.pixelHeight, + ); + connectionSizesRef.current.set(connection, size); + await connection.viewportReady(viewport); + }) + .catch(fail); + }, [active?.connection, fail]); + const send = (operation: Promise | undefined) => operation?.catch(fail); + const handleSplashStarted = React.useCallback(() => { + setSplashPending(false); + }, []); const handleSize = React.useCallback( (size: TerminalViewportSize) => { @@ -198,6 +348,7 @@ export function TerminalBootstrap({ size.pixelWidth, size.pixelHeight, ); + connectionSizesRef.current.set(connection, size); await connection.viewportReady(viewport); }) .catch(fail); @@ -205,14 +356,24 @@ export function TerminalBootstrap({ [activeKey, fail], ); + if (!panelMounted) return null; + return ( setTerminalPanelMode("closed")} + onModeChange={setTerminalPanelMode} + onToggle={toggleTerminalPanel} focusReportingEnabled={active?.frame?.focusReporting ?? false} frame={active?.frame} - sessionFrames={sessions.flatMap((session) => + viewportReportingEnabled={viewportReportingEnabled} + showSplash={splashPending} + onSplashStarted={handleSplashStarted} + sessionFrames={channelSessions.flatMap((session) => session.frame ? [{ sessionId: session.key, frame: session.frame }] : [], )} onCloseSession={(key) => { @@ -225,6 +386,7 @@ export function TerminalBootstrap({ (session) => session.key === key, )?.connection; if (!connection) { + closedSessionKeysRef.current.add(key); removeSession(key); return; } @@ -263,12 +425,14 @@ export function TerminalBootstrap({ send(active?.connection?.focus(focused)) } onViewportSize={handleSize} - sessions={sessions.map((session) => ({ - active: session.key === activeKey, - closing: session.closing, - id: session.key, - title: session.title, - }))} + sessions={channelSessions + .filter((session) => !session.closing) + .map((session) => ({ + active: session.key === activeKey, + closing: session.closing, + id: session.key, + title: session.title, + }))} /> ); } diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index dad8c0dd1..ef138396b 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -57,6 +57,7 @@ before(async () => { playbackRate: 1, reverse() {}, }); + dom.window.HTMLElement.prototype.setPointerCapture = () => {}; ({ act, cleanup, fireEvent, render, waitFor } = await import( "@testing-library/react" )); @@ -232,6 +233,89 @@ test("tab actions restore terminal input focus", async () => { } }); +test("drag resize batches visual updates and commits state only on release", async () => { + const { view } = fixture({ mode: "docked" }); + await ready(view); + const substrate = view.container.querySelector(".buzz-terminal-substrate"); + const handle = view.getByLabelText("Resize Buzz Term"); + + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 400, pointerId: 2 }); + fireEvent.pointerUp(handle, { clientY: 400, pointerId: 2 }); + assert.equal(substrate.dataset.terminalResizing, "true"); + fireEvent.pointerMove(handle, { clientY: 460, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + assert.equal(substrate.dataset.terminalResizing, "true"); + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null); + + await waitFor(() => assert.equal(substrate.style.height, "380px")); + fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + assert.equal(substrate.dataset.terminalResizing, undefined); + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), "380"); +}); + +test("drag resize repaints the canvas without reporting PTY geometry until release", async () => { + let canvasHeight = 280; + const viewportSizes = []; + dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({ + bottom: canvasHeight, + height: canvasHeight, + left: 0, + right: 940.8, + top: 0, + width: 940.8, + x: 0, + y: 0, + toJSON() {}, + }); + const { view } = fixture({ + mode: "docked", + onViewportSize(size) { + viewportSizes.push(size); + }, + }); + await ready(view); + const canvas = view.container.querySelector( + ".buzz-terminal-viewport > canvas:not(.buzz-terminal-welcome)", + ); + const handle = view.getByLabelText("Resize Buzz Term"); + await waitFor(() => assert.equal(canvas.height, 280)); + const reportsBeforeDrag = viewportSizes.length; + + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + canvasHeight = 340; + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + + await waitFor(() => assert.equal(canvas.height, 340)); + assert.equal( + viewportSizes.length, + reportsBeforeDrag, + "visual repaint must not resize the PTY during drag", + ); + + fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + await waitFor(() => assert.equal(viewportSizes.at(-1).pixelHeight, 340)); +}); + +test("unmount cancels a queued drag update", async () => { + const { view } = fixture({ mode: "docked" }); + await ready(view); + const handle = view.getByLabelText("Resize Buzz Term"); + const previousHeight = handle.closest(".buzz-terminal-substrate").style + .height; + + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + view.unmount(); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null); + assert.equal( + handle.closest(".buzz-terminal-substrate").style.height, + previousHeight, + ); +}); + const EMPTY_FRAME = { cursor: { column: 0, line: 0, visible: false }, full: false, @@ -275,74 +359,83 @@ async function reveal(view) { ); } -test("spawn-time output before the first reveal keeps the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); - await ready(subject.view); - await expectWelcome(subject.view, true); - - subject.rerender({ frame: VISIBLE_FRAME }); - await expectWelcome(subject.view, true); - - await reveal(subject.view); - await expectWelcome(subject.view, true); -}); - -test("the first keystroke dismisses the welcome overlay", async () => { - const subject = fixture({ frame: VISIBLE_FRAME }); +test("the first settled reveal runs one bounded splash", async () => { + let starts = 0; + const subject = fixture({ + frame: EMPTY_FRAME, + onSplashStarted() { + starts += 1; + }, + showSplash: true, + viewportReportingEnabled: false, + visible: true, + }); await ready(subject.view); - await reveal(subject.view); - await expectWelcome(subject.view, true); + await expectWelcome(subject.view, false); - fireEvent.input(subject.view.getByLabelText("Terminal input"), { - target: { value: "l" }, + subject.rerender({ + frame: EMPTY_FRAME, + onSplashStarted() { + starts += 1; + }, + showSplash: true, + viewportReportingEnabled: true, + visible: true, }); + await expectWelcome(subject.view, true); + assert.equal(starts, 1); + await act(async () => new Promise((resolve) => setTimeout(resolve, 2_550))); await expectWelcome(subject.view, false); }); -test("non-empty output after the reveal dismisses the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); +test("later reveals do not replay a consumed splash", async () => { + const subject = fixture({ + frame: EMPTY_FRAME, + showSplash: true, + viewportReportingEnabled: true, + visible: true, + }); await ready(subject.view); - await reveal(subject.view); await expectWelcome(subject.view, true); - subject.rerender({ frame: VISIBLE_FRAME }); + subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: false }); + await expectWelcome(subject.view, false); + subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: true }); await expectWelcome(subject.view, false); }); -test("empty active output keeps the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); - await ready(subject.view); - await reveal(subject.view); - await expectWelcome(subject.view, true); - - subject.rerender({ - frame: { - ...EMPTY_FRAME, - viewport: { ...EMPTY_FRAME.viewport, generation: 2 }, - }, +test("a consumed splash stays absent after substrate remount", async () => { + const first = fixture({ + frame: EMPTY_FRAME, + showSplash: true, + visible: true, }); - await expectWelcome(subject.view, true); + await ready(first.view); + await expectWelcome(first.view, true); + first.view.unmount(); + + const second = fixture({ + frame: EMPTY_FRAME, + showSplash: false, + visible: true, + }); + await ready(second.view); + await expectWelcome(second.view, false); }); -test("non-empty output from an inactive PTY keeps the welcome overlay", async () => { +test("the first keystroke dismisses the welcome overlay early", async () => { const subject = fixture({ - sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }], - sessions: [ - { active: true, closing: false, id: "one", title: "SHELL" }, - { active: false, closing: false, id: "two", title: "LOG" }, - ], + frame: EMPTY_FRAME, + showSplash: true, + visible: true, }); await ready(subject.view); - await reveal(subject.view); await expectWelcome(subject.view, true); - subject.rerender({ - sessionFrames: [ - { frame: EMPTY_FRAME, sessionId: "one" }, - { frame: VISIBLE_FRAME, sessionId: "two" }, - ], + fireEvent.input(subject.view.getByLabelText("Terminal input"), { + target: { value: "l" }, }); - await expectWelcome(subject.view, true); + await expectWelcome(subject.view, false); }); test("mounted wheel path accumulates fractional lines per active session", async () => { @@ -764,213 +857,3 @@ test("the handoff chord still toggles with the tab layer installed", async () => // Splash animation lifecycle. // // This substrate is mounted unconditionally on every route and merely -// CSS-concealed in Buzz mode, so "is the splash animating?" is a question about -// `owner`, not about mounting. A loop gated only on `welcomeVisible` ran at -// 120 rAF/s behind the whole app forever for anyone who never opened the -// terminal; that is the defect these arms exist to keep dead. -// -// The rAF clock is driven by hand rather than by jsdom's visual loop: a real -// clock can only show "frames happened", while a manual one can advance AFTER a -// transition and prove no successor callback was scheduled. Distinguishing a -// cancelled frame from a frame that was never scheduled needs that. -function splashClock() { - const real = { - request: dom.window.requestAnimationFrame, - cancel: dom.window.cancelAnimationFrame, - }; - const pending = new Map(); - let nextHandle = 1; - let scheduled = 0; - let cancelled = 0; - dom.window.requestAnimationFrame = (callback) => { - const handle = nextHandle++; - pending.set(handle, callback); - scheduled += 1; - return handle; - }; - dom.window.cancelAnimationFrame = (handle) => { - if (pending.delete(handle)) cancelled += 1; - }; - return { - get scheduled() { - return scheduled; - }, - get cancelled() { - return cancelled; - }, - get outstanding() { - return pending.size; - }, - /** Run every queued callback once, as one frame would. */ - advance(now = 16) { - const due = [...pending.entries()]; - pending.clear(); - act(() => { - for (const [, callback] of due) callback(now); - }); - return due.length; - }, - restore() { - dom.window.requestAnimationFrame = real.request; - dom.window.cancelAnimationFrame = real.cancel; - }, - }; -} - -/** Draws issued to the banner/splash canvas only. */ -function bannerDraws(view) { - const banner = view.container.querySelector(".buzz-terminal-welcome"); - if (!banner) return []; - return paintLog.filter((entry) => entry.canvas === banner); -} - -test("the splash animation runs only while the terminal is revealed", async () => { - const clock = splashClock(); - try { - // ARM 1 — concealed entry. `enabled` defaults true, so this is the - // production-shaped state that used to animate behind the channel view. - const subject = fixture(); - await ready(subject.view); - const substrate = subject.view.container.querySelector( - ".buzz-terminal-substrate", - ); - assert.equal(substrate.dataset.terminalOwner, "buzz"); - await expectWelcome(subject.view, true); - assert.equal( - clock.scheduled, - 0, - "ARM 1: no splash frame is scheduled while Buzz owns the screen", - ); - clock.advance(); - assert.equal( - bannerDraws(subject.view).length, - 0, - "ARM 1: and no hidden banner paint happens either", - ); - - // ARM 2 — revealed. The positive control: the loop must actually run, or - // arms 1/3 are satisfied by a loop that is simply broken everywhere. - await reveal(subject.view); - assert.ok( - clock.scheduled > 0, - "ARM 2: revealing the terminal starts the splash loop", - ); - const afterReveal = clock.scheduled; - assert.equal(clock.advance(), 1, "ARM 2: exactly one frame was pending"); - assert.ok( - bannerDraws(subject.view).length > 0, - "ARM 2: the revealed splash actually paints", - ); - assert.ok( - clock.scheduled > afterReveal, - "ARM 2: the loop reschedules itself while revealed", - ); - - // ARM 3 — terminal -> buzz. The regression users hit: leaving the terminal - // must CANCEL the outstanding frame, not merely stop new ones. Advancing - // the clock afterwards is what separates those two. - const beforeConceal = clock.scheduled; - const cancelledBefore = clock.cancelled; - toggleChord(); - await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz")); - assert.ok( - clock.cancelled > cancelledBefore, - "ARM 3: concealing cancels the frame that was already scheduled", - ); - assert.equal( - clock.outstanding, - 0, - "ARM 3: nothing is left queued after cleanup", - ); - const drawsBefore = bannerDraws(subject.view).length; - clock.advance(); - clock.advance(); - assert.equal( - clock.scheduled, - beforeConceal, - "ARM 3: no successor callback is scheduled after concealing", - ); - assert.equal( - bannerDraws(subject.view).length, - drawsBefore, - "ARM 3: and no further hidden banner paint occurs", - ); - - // ARM 4 — buzz -> terminal again. Proves cleanup did not poison the - // positive path: a fix that permanently kills the loop passes 1 and 3. - await reveal(subject.view); - assert.ok( - clock.scheduled > beforeConceal, - "ARM 4: re-revealing restarts the splash loop", - ); - clock.advance(); - assert.ok( - bannerDraws(subject.view).length > drawsBefore, - "ARM 4: and it paints again", - ); - } finally { - clock.restore(); - } -}); - -// The gate above reads `owner` alone, which is only sound because -// `owner === "terminal"` implies `enabled`: the sole `commitOwner("terminal")` -// call site sits behind an `!enabled` early return, and dropping `enabled` -// forces ownership back to Buzz. That implication is true by construction -// today and nothing else in this file pins it, so a refactor adding a second -// reveal path outside the `enabled` guard would silently widen the gate. This -// arm is that implication, held as a regression test in both directions. -test("a disabled terminal cannot reveal, so owner-gating cannot widen", async () => { - const clock = splashClock(); - try { - const subject = fixture({ enabled: false }); - await ready(subject.view); - const substrate = subject.view.container.querySelector( - ".buzz-terminal-substrate", - ); - - toggleChord(); - await waitFor(() => assert.ok(substrate.dataset.terminalOwner)); - assert.equal( - substrate.dataset.terminalOwner, - "buzz", - "the toggle chord must not reveal a terminal that has no session", - ); - assert.equal( - clock.scheduled, - 0, - "and no splash frame is scheduled while disabled", - ); - clock.advance(); - assert.equal( - bannerDraws(subject.view).length, - 0, - "nor any hidden banner paint", - ); - - // The other direction: losing `enabled` while revealed must concede - // ownership and cancel the loop, which is what makes the `enabled` term - // redundant in the animation gate rather than merely absent from it. - subject.rerender({ enabled: true }); - await reveal(subject.view); - assert.ok(clock.scheduled > 0, "the enabled terminal does reveal and run"); - const afterReveal = clock.scheduled; - - subject.rerender({ enabled: false }); - await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz")); - assert.equal( - clock.outstanding, - 0, - "losing the session cancels the outstanding splash frame", - ); - clock.advance(); - clock.advance(); - assert.equal( - clock.scheduled, - afterReveal, - "and schedules no successor once disabled", - ); - } finally { - clock.restore(); - } -}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index f1414d7d5..6f3735fa3 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -1,9 +1,9 @@ import * as React from "react"; +import { ChevronRight, Maximize2, Minimize2, Plus, X } from "lucide-react"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; -import { FadeController } from "./fadeController"; import { INITIAL_HANDOFF_STATE, accumulateScrollLines, @@ -37,7 +37,6 @@ export type TerminalSessionTab = { }; type TerminalSubstrateProps = { - appSurfaceRef?: React.RefObject; channelName: string | null; frame?: TerminalFrame; sessionFrames?: readonly { sessionId: string; frame: TerminalFrame }[]; @@ -45,8 +44,16 @@ type TerminalSubstrateProps = { bracketedPaste: boolean; focusReportingEnabled: boolean; enabled?: boolean; + mode?: "docked" | "maximized"; + visible?: boolean; + onHide?: () => void; + onModeChange?: (mode: "docked" | "maximized") => void; + onToggle?: () => void; onFrameConsumed?: (frame: TerminalFrame) => void; onViewportSize?: (size: TerminalViewportSize) => void; + viewportReportingEnabled?: boolean; + showSplash?: boolean; + onSplashStarted?: () => void; onInput: (text: string) => void; /** Whole cells scrolled, keeping the DOM's sign: negative goes back. */ onScroll: (lines: number) => void; @@ -66,26 +73,26 @@ function isToggleChord(event: KeyboardEvent): boolean { } const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS; - -function hasVisibleOutput(frame: TerminalFrame): boolean { - return frame.rows.some((row) => - row.spans.some((span) => - span.clusters.some((cluster) => cluster.text.trim().length > 0), - ), - ); -} +const NOOP = () => {}; +const SPLASH_DURATION_MS = 2_500; export function TerminalSubstrate({ - appSurfaceRef, - channelName, frame, sessionFrames, sessions, bracketedPaste, focusReportingEnabled, enabled = true, + mode = "docked", + visible = true, + onHide = NOOP, + onModeChange = NOOP, + onToggle, onFrameConsumed, onViewportSize, + viewportReportingEnabled = true, + showSplash = true, + onSplashStarted, onInput, onScroll, onTerminalFocusChange, @@ -97,17 +104,20 @@ export function TerminalSubstrate({ const canvasRef = React.useRef(null); const bannerCanvasRef = React.useRef(null); const textareaRef = React.useRef(null); - const fadeRef = React.useRef(null); const handoffRef = React.useRef(INITIAL_HANDOFF_STATE); const gridsRef = React.useRef(new Map()); const appliedFramesRef = React.useRef(new WeakSet()); const gridRef = React.useRef(null); const paintedPaletteRef = React.useRef(terminalPalette); const paintedSessionRef = React.useRef(null); - const previousFocusRef = React.useRef(null); const reportedFocusRef = React.useRef(null); + const reportedViewportSizeRef = React.useRef( + null, + ); + const dragCleanupRef = React.useRef<(() => void) | null>(null); + const resizeReportFrameRef = React.useRef(0); + const resizingRef = React.useRef(false); const scrollBySessionRef = React.useRef(new Map()); - const revealedRef = React.useRef(false); const activeSession = sessions.find((session) => session.active); const activeSessionId = activeSession?.id ?? null; const frames = React.useMemo( @@ -118,21 +128,19 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); - const [welcomeVisible, setWelcomeVisible] = React.useState(true); + const [welcomeVisible, setWelcomeVisible] = React.useState(false); const [cursorPainted, setCursorPainted] = React.useState(true); const [cursorReset, setCursorReset] = React.useState(0); const [reducedMotion, setReducedMotion] = React.useState( () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, ); - const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) - ? "⌘J" - : "CTRL+J"; - const getAppSurface = React.useCallback( - () => - appSurfaceRef?.current ?? - document.querySelector(".buzz-huddle-app-surface"), - [appSurfaceRef], - ); + const [dockHeight, setDockHeight] = React.useState(() => { + const stored = Number.parseInt( + window.localStorage.getItem("buzz-terminal-dock-height") ?? "", + 10, + ); + return Number.isFinite(stored) ? stored : 320; + }); const banner = React.useMemo( () => buildTerminalBanner( @@ -149,32 +157,9 @@ export function TerminalSubstrate({ } as React.CSSProperties) : undefined; - const commitOwner = React.useEffectEvent((next: "buzz" | "terminal") => { - const appSurface = getAppSurface(); - if (!appSurface) return; - if (next === "terminal") { - revealedRef.current = true; - previousFocusRef.current = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - appSurface.inert = true; - appSurface.setAttribute("aria-hidden", "true"); - textareaRef.current?.focus({ preventScroll: true }); - } else { - appSurface.inert = false; - appSurface.removeAttribute("aria-hidden"); - const previous = previousFocusRef.current; - if (previous?.isConnected) previous.focus({ preventScroll: true }); - else appSurface.focus({ preventScroll: true }); - } - setOwner(next); - }); - const forceBuzzFallback = React.useEffectEvent(() => { handoffRef.current = { ...INITIAL_HANDOFF_STATE }; - commitOwner("buzz"); - fadeRef.current?.settle("conceal"); + setOwner("buzz"); }); const sendInput = React.useEffectEvent((text: string) => { @@ -187,6 +172,12 @@ export function TerminalSubstrate({ const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => { onFrameConsumed?.(nextFrame); }); + const beginSplash = React.useEffectEvent(() => { + if (!showSplash) return false; + onSplashStarted?.(); + setWelcomeVisible(true); + return true; + }); /** * Tab chords are handled at the window in capture phase, like the ⌘J * handoff, so they win over the focused textarea. Gated on terminal @@ -212,6 +203,7 @@ export function TerminalSubstrate({ return true; }); const reportViewportSize = React.useEffectEvent(() => { + if (!viewportReportingEnabled) return; const canvas = canvasRef.current; if (!canvas) return; const bounds = canvas.getBoundingClientRect(); @@ -220,14 +212,33 @@ export function TerminalSubstrate({ const pixelHeight = Math.max(1, Math.round(bounds.height * dpr)); const columns = Math.max(1, Math.floor(bounds.width / CELL_WIDTH)); const rows = Math.max(1, Math.floor(bounds.height / CELL_HEIGHT)); + const size = { columns, rows, pixelWidth, pixelHeight }; + if (resizingRef.current) return; setViewport((current) => current.columns === columns && current.rows === rows ? current : { columns, rows }, ); - onViewportSize?.({ columns, rows, pixelWidth, pixelHeight }); + const reported = reportedViewportSizeRef.current; + if ( + reported?.columns === columns && + reported.rows === rows && + reported.pixelWidth === pixelWidth && + reported.pixelHeight === pixelHeight + ) + return; + reportedViewportSizeRef.current = size; + onViewportSize?.(size); }); + React.useEffect( + () => () => { + dragCleanupRef.current?.(); + window.cancelAnimationFrame(resizeReportFrameRef.current); + }, + [], + ); + React.useEffect(() => { if (!enabled) forceBuzzFallback(); }, [enabled]); @@ -258,11 +269,15 @@ export function TerminalSubstrate({ reportViewportSize(); const ResizeObserverConstructor = window.ResizeObserver; if (!ResizeObserverConstructor) return; - const observer = new ResizeObserverConstructor(reportViewportSize); + const observer = new ResizeObserverConstructor(() => reportViewportSize()); observer.observe(canvas); return () => observer.disconnect(); }, []); + React.useLayoutEffect(() => { + if (viewportReportingEnabled) reportViewportSize(); + }, [viewportReportingEnabled]); + React.useEffect(() => { if (!focusReportingEnabled) { reportedFocusRef.current = null; @@ -284,92 +299,71 @@ export function TerminalSubstrate({ }, [focusReportingEnabled, onTerminalFocusChange, owner]); React.useLayoutEffect(() => { - const appSurface = getAppSurface(); - if (!appSurface) return; - fadeRef.current = new FadeController(appSurface); + if (!enabled) { + forceBuzzFallback(); + return; + } const handleKeyDown = (event: KeyboardEvent) => { - if (!enabled) return; if (runTabChord(event)) { event.preventDefault(); event.stopImmediatePropagation(); return; } - if (!isToggleChord(event)) return; - if (event.isComposing) { - handoffRef.current = reduceHandoff(handoffRef.current, { - type: "focus-lost", - }).state; - return; - } + if (!isToggleChord(event) || event.isComposing) return; event.preventDefault(); event.stopImmediatePropagation(); - const result = reduceHandoff(handoffRef.current, { - type: "chord-down", - repeat: event.repeat, - }); - handoffRef.current = result.state; }; const handleKeyUp = (event: KeyboardEvent) => { - if (!enabled || !isToggleChord(event)) return; + if (!isToggleChord(event) || event.isComposing) return; event.preventDefault(); event.stopImmediatePropagation(); - if (event.isComposing) return; - const result = reduceHandoff(handoffRef.current, { type: "chord-up" }); - handoffRef.current = result.state; - if (!result.toggled) return; - commitOwner(result.state.owner); - fadeRef.current?.toggle( - window.matchMedia("(prefers-reduced-motion: reduce)").matches, - ); - }; - const cancelChord = () => { - handoffRef.current = reduceHandoff(handoffRef.current, { - type: "focus-lost", - }).state; + if (onToggle) onToggle(); + else { + setOwner((current) => { + const next = current === "terminal" ? "buzz" : "terminal"; + if (next === "terminal") { + textareaRef.current?.focus({ preventScroll: true }); + } + return next; + }); + } }; window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keyup", handleKeyUp, true); - window.addEventListener("blur", cancelChord); - document.addEventListener("visibilitychange", cancelChord); + if (onToggle) { + setOwner("terminal"); + textareaRef.current?.focus({ preventScroll: true }); + } return () => { - fadeRef.current?.settle("conceal"); - fadeRef.current = null; - appSurface.inert = false; - appSurface.removeAttribute("aria-hidden"); window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keyup", handleKeyUp, true); - window.removeEventListener("blur", cancelChord); - document.removeEventListener("visibilitychange", cancelChord); }; - }, [enabled, getAppSurface]); + }, [enabled, onToggle]); - // The banner's animation loop. It runs only while the splash is ON SCREEN, - // which needs BOTH conditions below — they are different questions: - // - `welcomeVisible`: the splash has not been dismissed by terminal output. - // - `owner === "terminal"`: the terminal layer is revealed at all. - // - // `owner` is the load-bearing one and it is not optional. This substrate is - // mounted unconditionally by AppShell on every route and merely CSS-concealed - // in Buzz mode (`.buzz-terminal-substrate` is `position:absolute; inset:0`), - // and `welcomeVisible` starts `true` and only clears on terminal INPUT. So a - // loop gated on `welcomeVisible` alone runs forever behind the whole app for - // anyone who never opens the terminal — measured at 120 rAF/s in the channel - // view, repainting a canvas nobody can see and slowing every other paint. - // - // Deliberately NOT gated on `enabled`: that is `available && Boolean(active)` - // where `available` is `isTauri()`, and a session is auto-created on channel - // open (TerminalBootstrap), so `enabled` is true while still concealed in the - // app and permanently false in the browser — it would gate the tests green - // and leave real users paying the cost. `owner` is user-gestured in both. - // - // `prefers-reduced-motion` takes the STATIC path (no motion argument), which - // is the shipped painter call, not a paused animation. Those are different: - // a stopped loop still parks on whatever phase it halted at. React.useEffect(() => { - const canvas = bannerCanvasRef.current; - if (!canvas || !banner || !terminalPalette || !welcomeVisible) return; - if (owner !== "terminal") return; + if (!visible) { + setWelcomeVisible(false); + return; + } + if (!viewportReportingEnabled || !banner || !beginSplash()) return; + }, [banner, viewportReportingEnabled, visible]); + + React.useEffect(() => { + if (!welcomeVisible) return; + const timeout = window.setTimeout( + () => setWelcomeVisible(false), + SPLASH_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [welcomeVisible]); + // The splash is a bounded decoration, never a PTY-readiness gate. Each open + // gets one animation epoch; input may dismiss it early and the deadline ends + // it unconditionally even when an idle shell emits no new frames. + React.useEffect(() => { + const canvas = bannerCanvasRef.current; + if (!canvas || !banner || !terminalPalette || !welcomeVisible || !visible) + return; const dpr = window.devicePixelRatio || 1; if (reducedMotion) { if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr)) @@ -377,7 +371,6 @@ export function TerminalSubstrate({ return; } - // Built once per palette, not per frame: the table is phase-independent. const table = buildBannerColorTable(terminalPalette); const start = performance.now(); let frame = 0; @@ -395,47 +388,11 @@ export function TerminalSubstrate({ }; frame = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(frame); - }, [banner, owner, reducedMotion, terminalPalette, welcomeVisible]); - - React.useEffect(() => { - for (const delivered of frames) { - if (appliedFramesRef.current.has(delivered.frame)) continue; - appliedFramesRef.current.add(delivered.frame); - let grid = gridsRef.current.get(delivered.sessionId); - if (!grid) { - grid = new TerminalGrid(delivered.frame.viewport); - gridsRef.current.set(delivered.sessionId, grid); - } else if ( - grid.viewport.generation !== delivered.frame.viewport.generation || - grid.viewport.columns !== delivered.frame.viewport.columns || - grid.viewport.screenLines !== delivered.frame.viewport.screenLines - ) { - grid.resize(delivered.frame.viewport); - } - grid.apply(delivered.frame); - consumeFrame(delivered.frame); - if ( - delivered.sessionId === activeSessionId && - revealedRef.current && - hasVisibleOutput(delivered.frame) - ) { - // Policy: the banner is a splash for the reveal, so spawn-time shell - // output must not dismiss it. Only visible output from the active PTY - // that arrives after the terminal has been revealed (or the first - // keystroke, see sendInput) removes the overlay. - setWelcomeVisible(false); - } - } - gridRef.current = activeSessionId - ? (gridsRef.current.get(activeSessionId) ?? null) - : null; + }, [banner, reducedMotion, terminalPalette, visible, welcomeVisible]); + const paintTerminal = React.useEffectEvent(() => { const canvas = canvasRef.current; - if (!canvas) return; - if (!terminalPalette) { - forceBuzzFallback(); - return; - } + if (!canvas || !terminalPalette) return; const context = canvas.getContext("2d", { alpha: false }); if (!context) { forceBuzzFallback(); @@ -473,6 +430,34 @@ export function TerminalSubstrate({ } gridRef.current?.setCursorPainted(cursorPainted); gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette); + }); + + // Palette and blink changes must trigger a repaint; paintTerminal is an + // Effect Event, so the dependency analyzer cannot see those reads. + // biome-ignore lint/correctness/useExhaustiveDependencies: visual-only inputs intentionally trigger this paint effect. + React.useEffect(() => { + for (const delivered of frames) { + if (appliedFramesRef.current.has(delivered.frame)) continue; + appliedFramesRef.current.add(delivered.frame); + let grid = gridsRef.current.get(delivered.sessionId); + if (!grid) { + grid = new TerminalGrid(delivered.frame.viewport); + gridsRef.current.set(delivered.sessionId, grid); + } else if ( + grid.viewport.generation !== delivered.frame.viewport.generation || + grid.viewport.columns !== delivered.frame.viewport.columns || + grid.viewport.screenLines !== delivered.frame.viewport.screenLines + ) { + grid.resize(delivered.frame.viewport); + } + grid.apply(delivered.frame); + consumeFrame(delivered.frame); + } + gridRef.current = activeSessionId + ? (gridsRef.current.get(activeSessionId) ?? null) + : null; + + paintTerminal(); }, [activeSessionId, cursorPainted, frames, terminalPalette]); const runTabAction = (action: () => void) => { @@ -486,8 +471,13 @@ export function TerminalSubstrate({
{ event.preventDefault(); const sessionId = activeSession?.id; @@ -507,6 +497,103 @@ export function TerminalSubstrate({ if (result.lines !== 0) onScroll(result.lines); }} > + {mode === "docked" ? ( +
{ + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + const delta = event.key === "ArrowUp" ? 16 : -16; + const next = Math.max( + 180, + Math.min(window.innerHeight * 0.7, dockHeight + delta), + ); + setDockHeight(next); + window.localStorage.setItem( + "buzz-terminal-dock-height", + String(Math.round(next)), + ); + }} + onPointerDown={(event) => { + event.preventDefault(); + dragCleanupRef.current?.(); + window.cancelAnimationFrame(resizeReportFrameRef.current); + const handle = event.currentTarget; + const substrate = handle.closest( + ".buzz-terminal-substrate", + ); + if (!substrate) return; + const pointerId = event.pointerId; + handle.setPointerCapture(pointerId); + resizingRef.current = true; + substrate.dataset.terminalResizing = "true"; + const startY = event.clientY; + const startHeight = dockHeight; + let nextHeight = startHeight; + let frame = 0; + const applyHeight = () => { + frame = 0; + substrate.style.height = `${nextHeight}px`; + // Repaint the canvas at its new CSS size in the same visual + // frame. PTY geometry is still reported only on release, but + // leaving the old backing bitmap in a `height: 100%` canvas + // makes the browser stretch terminal rows during the drag. + paintTerminal(); + }; + const cleanup = () => { + window.cancelAnimationFrame(frame); + frame = 0; + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", finish); + handle.removeEventListener("pointercancel", finish); + if (dragCleanupRef.current === cleanup) + dragCleanupRef.current = null; + }; + const move = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + nextHeight = Math.max( + 180, + Math.min( + window.innerHeight * 0.7, + startHeight + startY - moveEvent.clientY, + ), + ); + if (!frame) frame = window.requestAnimationFrame(applyHeight); + }; + const finish = (finishEvent: PointerEvent) => { + if (finishEvent.pointerId !== pointerId) return; + if (frame) { + window.cancelAnimationFrame(frame); + applyHeight(); + } + cleanup(); + resizingRef.current = false; + delete substrate.dataset.terminalResizing; + setDockHeight(nextHeight); + window.localStorage.setItem( + "buzz-terminal-dock-height", + String(Math.round(nextHeight)), + ); + resizeReportFrameRef.current = window.requestAnimationFrame( + () => { + resizeReportFrameRef.current = 0; + if (!resizingRef.current) reportViewportSize(); + }, + ); + }; + dragCleanupRef.current = cleanup; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", finish); + handle.addEventListener("pointercancel", finish); + }} + tabIndex={0} + /> + ) : null}
{sessions.map((session, index) => ( @@ -519,26 +606,36 @@ export function TerminalSubstrate({ role="presentation" >
))} @@ -548,20 +645,36 @@ export function TerminalSubstrate({ onClick={() => runTabAction(onNewSession)} type="button" > - + +
- {channelName ? `#${channelName}` : "BUZZ"} - LOCAL PTY · PRIVATE - {shortcutLabel} BUZZ + +
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
{ - if (owner !== "terminal") return; // Preventing the canvas mousedown also suppresses selection. Revisit // this when the terminal gains mouse selection support. event.preventDefault(); @@ -616,7 +729,7 @@ export function TerminalSubstrate({ }} ref={textareaRef} spellCheck={false} - tabIndex={owner === "terminal" ? 0 : -1} + tabIndex={0} />
diff --git a/desktop/src/features/terminal/terminalPanelStore.test.mjs b/desktop/src/features/terminal/terminalPanelStore.test.mjs new file mode 100644 index 000000000..8a3020f0c --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { + resetTerminalPanelForTests, + setTerminalPanelMode, + setTerminalSessionChannels, + toggleTerminalPanel, + getTerminalPanelSnapshotForTests, +} from "./terminalPanelStore.ts"; + +beforeEach(resetTerminalPanelForTests); + +test("panel toggles between closed and the docked default", () => { + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "docked"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); + setTerminalPanelMode("maximized"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); +}); + +test("session channel identities are de-duplicated", () => { + setTerminalSessionChannels(["one", "one", "two"]); + // Regression guard: accepting an iterable (rather than Session objects) keeps + // this store UI-only and prevents mutable PTYs from leaking into header state. + setTerminalSessionChannels(new Set(["one", "two"])); + assert.deepEqual( + [...getTerminalPanelSnapshotForTests().sessionChannelIds], + ["one", "two"], + ); +}); diff --git a/desktop/src/features/terminal/terminalPanelStore.ts b/desktop/src/features/terminal/terminalPanelStore.ts new file mode 100644 index 000000000..3c8fa778a --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +export type TerminalPanelMode = "closed" | "docked" | "maximized"; + +type Snapshot = { + mode: TerminalPanelMode; + sessionChannelIds: ReadonlySet; +}; + +let snapshot: Snapshot = { mode: "closed", sessionChannelIds: new Set() }; +const listeners = new Set<() => void>(); + +function publish(next: Snapshot) { + snapshot = next; + for (const listener of listeners) listener(); +} + +export function setTerminalPanelMode(mode: TerminalPanelMode) { + if (snapshot.mode === mode) return; + publish({ ...snapshot, mode }); +} + +export function toggleTerminalPanel() { + setTerminalPanelMode(snapshot.mode === "closed" ? "docked" : "closed"); +} + +export function setTerminalSessionChannels(channelIds: Iterable) { + const next = new Set(channelIds); + if ( + next.size === snapshot.sessionChannelIds.size && + [...next].every((id) => snapshot.sessionChannelIds.has(id)) + ) + return; + publish({ ...snapshot, sessionChannelIds: next }); +} + +export function useTerminalPanel() { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +export function resetTerminalPanelForTests() { + snapshot = { mode: "closed", sessionChannelIds: new Set() }; +} + +export function getTerminalPanelSnapshotForTests() { + return snapshot; +} diff --git a/desktop/src/features/threads/recentThreadsStore.ts b/desktop/src/features/threads/recentThreadsStore.ts new file mode 100644 index 000000000..8c78fa7c9 --- /dev/null +++ b/desktop/src/features/threads/recentThreadsStore.ts @@ -0,0 +1,166 @@ +import * as React from "react"; + +const STORAGE_KEY_PREFIX = "buzz-recent-threads.v1"; +const MAX_RECENT_THREADS = 15; +const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1_000; + +export type RecentThread = { + rootId: string; + channelId: string; + channelName: string; + preview: string; + lastActivityAt: number; +}; + +type RecentThreadsStore = { + version: 1; + threads: RecentThread[]; +}; + +const DEFAULT_STORE: RecentThreadsStore = { version: 1, threads: [] }; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +function parsePayload(json: unknown): RecentThreadsStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1 || !Array.isArray(obj.threads)) return null; + const threads = obj.threads.filter( + (t): t is RecentThread => + typeof t === "object" && + t !== null && + typeof (t as RecentThread).rootId === "string" && + typeof (t as RecentThread).channelId === "string" && + typeof (t as RecentThread).channelName === "string" && + typeof (t as RecentThread).preview === "string" && + typeof (t as RecentThread).lastActivityAt === "number", + ); + return { version: 1, threads }; +} + +function readStore(pubkey: string): RecentThreadsStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) return DEFAULT_STORE; + return parsePayload(JSON.parse(raw)) ?? DEFAULT_STORE; + } catch { + return DEFAULT_STORE; + } +} + +function writeStore(pubkey: string, store: RecentThreadsStore): void { + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + } catch { + // Best-effort persistence — a full localStorage quota shouldn't crash + // thread tracking, it just won't survive a reload. + } +} + +function pruneStale( + store: RecentThreadsStore, + now: number, +): RecentThreadsStore { + const threads = store.threads.filter( + (t) => now - t.lastActivityAt <= STALE_AFTER_MS, + ); + if (threads.length === store.threads.length) return store; + return { ...store, threads }; +} + +const listeners = new Set<() => void>(); +let snapshot: RecentThreadsStore = DEFAULT_STORE; +let currentPubkey: string | undefined; + +function notify(): void { + for (const listener of listeners) listener(); +} + +function loadForScope(pubkey: string | undefined): void { + currentPubkey = pubkey; + snapshot = pubkey ? pruneStale(readStore(pubkey), Date.now()) : DEFAULT_STORE; + notify(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): RecentThreadsStore { + return snapshot; +} + +/** + * Records that a thread panel was opened (or received a new reply), moving + * it to the front of the recent-threads list so it can be reopened from the + * sidebar later. Caps the list at `MAX_RECENT_THREADS` and drops the oldest + * entries first. + */ +export function recordRecentThread(input: { + pubkey: string; + rootId: string; + channelId: string; + channelName: string; + preview: string; +}): void { + if (currentPubkey !== input.pubkey) { + loadForScope(input.pubkey); + } + const withoutExisting = snapshot.threads.filter( + (t) => t.rootId !== input.rootId, + ); + const next: RecentThreadsStore = { + version: 1, + threads: [ + { + rootId: input.rootId, + channelId: input.channelId, + channelName: input.channelName, + preview: input.preview, + lastActivityAt: Date.now(), + }, + ...withoutExisting, + ].slice(0, MAX_RECENT_THREADS), + }; + snapshot = next; + writeStore(input.pubkey, next); + notify(); +} + +export function removeRecentThread(pubkey: string, rootId: string): void { + if (currentPubkey !== pubkey) { + loadForScope(pubkey); + } + const next: RecentThreadsStore = { + version: 1, + threads: snapshot.threads.filter((t) => t.rootId !== rootId), + }; + snapshot = next; + writeStore(pubkey, next); + notify(); +} + +/** Reset on community/relay switch — module state is scoped per-call via loadForScope. */ +export function resetRecentThreadsStore(): void { + snapshot = DEFAULT_STORE; + currentPubkey = undefined; + notify(); +} + +export function useRecentThreads(pubkey: string | undefined): RecentThread[] { + React.useEffect(() => { + loadForScope(pubkey); + }, [pubkey]); + + const store = React.useSyncExternalStore( + subscribe, + getSnapshot, + () => DEFAULT_STORE, + ); + return pubkey ? store.threads : DEFAULT_STORE.threads; +} diff --git a/desktop/src/features/threads/useRecordRecentThread.ts b/desktop/src/features/threads/useRecordRecentThread.ts new file mode 100644 index 000000000..d3887f2a0 --- /dev/null +++ b/desktop/src/features/threads/useRecordRecentThread.ts @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { recordRecentThread } from "@/features/threads/recentThreadsStore"; + +/** + * Tracks every thread panel the viewer opens (regardless of entry point — + * @-mention, notification click-through, search) so it can be reopened + * later from the "Active Threads" sidebar section. Keyed by rootId; only + * records once the head message has actually resolved so the sidebar entry + * has a real preview instead of a blank placeholder. + */ +export function useRecordRecentThread(args: { + currentPubkey: string | undefined; + threadRootId: string | null; + channelId: string | null; + channelName: string; + threadHeadBody: string | undefined; +}): void { + const { + currentPubkey, + threadRootId, + channelId, + channelName, + threadHeadBody, + } = args; + + React.useEffect(() => { + if (!currentPubkey || !threadRootId || !channelId) return; + if (threadHeadBody === undefined) return; + recordRecentThread({ + pubkey: currentPubkey, + rootId: threadRootId, + channelId, + channelName, + preview: threadHeadBody.slice(0, 140), + }); + }, [currentPubkey, threadRootId, channelId, channelName, threadHeadBody]); +} diff --git a/desktop/src/shared/api/relayAuthPolicy.test.mjs b/desktop/src/shared/api/relayAuthPolicy.test.mjs new file mode 100644 index 000000000..e2b709c0e --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AuthOkTracker, + MAX_CONSECUTIVE_AUTH_REJECTIONS, +} from "./relayAuthPolicy.ts"; + +test("success resolves authenticated and resets the streak", () => { + const tracker = new AuthOkTracker(); + tracker.record(false, "auth-required: verification failed"); + tracker.record(false, "auth-required: verification failed"); + assert.equal(tracker.record(true, ""), "authenticated"); + // Streak reset: the next rejection starts a fresh count. + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); + +test("restricted rejections latch terminal immediately", () => { + for (const message of [ + "restricted: not a relay member", + "restricted: you are banned", + ]) { + assert.equal(new AuthOkTracker().record(false, message), "terminal"); + } +}); + +test("the relay's actual ban string latches terminal immediately", () => { + // Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the + // ban seam. A known-permanent ban must never enter the retry loop. + assert.equal( + new AuthOkTracker().record( + false, + "blocked: you are banned from this community", + ), + "terminal", + ); +}); + +test("verification failures retry with backoff (clock skew, DB fail-closed)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("unknown rejection reasons retry rather than latch", () => { + assert.equal(new AuthOkTracker().record(false, ""), "retry"); + assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry"); +}); + +test("consecutive rejections latch terminal at the cap", () => { + const tracker = new AuthOkTracker(); + let decision = "retry"; + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + decision = tracker.record(false, "auth-required: verification failed"); + } + assert.equal(decision, "terminal"); +}); + +test("reset grants a fresh retry streak after explicit re-engagement", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + tracker.record(false, "auth-required: verification failed"); + } + tracker.reset(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated wins even past the cap (session is usable)", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) { + tracker.record(false, "auth-required: verification failed"); + } + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts new file mode 100644 index 000000000..2d76e0c3f --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.ts @@ -0,0 +1,65 @@ +/** + * Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit). + * + * Historically ANY auth `OK false` latched the session terminal — no + * reconnect until explicit user re-engagement. But the relay sends + * `OK false` for conditions that are transient from the client's side: + * + * - `auth-required: already authenticated` — a duplicate/late AUTH event on + * a connection that is in fact authenticated. The session is usable; + * treat it as authenticated. + * - `auth-required: verification failed` — covers ±60s clock-skew rejects + * and the relay's fail-closed allowlist DB lookup errors, both of which + * can clear on retry. + * + * Only `restricted:` and `blocked:` rejections (not a relay member / banned) + * are known permanent. Everything else retries with normal backoff, but + * latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive + * rejections so a genuinely broken identity (e.g. persistently wrong system + * clock) still surfaces the terminal error card instead of flapping forever. + * + * The rejection streak is preserved across environment-driven resume + * attempts (focus/online/visibility); only explicit user re-engagement — + * the reconnect card or a community switch — may reset it. + */ +export type AuthOkDecision = "authenticated" | "retry" | "terminal"; + +export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3; + +/** Tracks consecutive AUTH rejections across reconnect attempts. */ +export class AuthOkTracker { + private consecutiveRejections = 0; + + /** + * Record an AUTH `OK` and decide the session's next move. + * A success — real or "already authenticated" — resets the streak. + */ + record(success: boolean, message: string): AuthOkDecision { + const normalized = message.trim().toLowerCase(); + if ( + success || + normalized.startsWith("auth-required: already authenticated") + ) { + this.consecutiveRejections = 0; + return "authenticated"; + } + + this.consecutiveRejections++; + + if ( + normalized.startsWith("restricted:") || + normalized.startsWith("blocked:") + ) { + return "terminal"; + } + if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) { + return "terminal"; + } + return "retry"; + } + + /** Called on explicit re-engagement (disconnect / manual preconnect). */ + reset(): void { + this.consecutiveRejections = 0; + } +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 53d541ff0..fd6758f79 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm import { isServiceRestartClose, isWebSocketClose, + isWebSocketError, shouldRefuseConnect, shouldScheduleReconnect, shouldWaitForScheduledReconnect, @@ -65,6 +66,7 @@ import { STALL_IDLE_TIMEOUT_MS, } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; +import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; export class RelayClient { @@ -92,6 +94,7 @@ export class RelayClient { private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; + private authOkTracker = new AuthOkTracker(); private terminal = false; @@ -128,6 +131,7 @@ export class RelayClient { this.notifyReconnectListeners = false; this.terminal = false; this.visibleChannelId = null; + this.authOkTracker.reset(); this.connectionStateEmitter.set("idle"); if (this.wsId !== null) { @@ -295,7 +299,7 @@ export class RelayClient { parentEventId?: string | null, rootEventId?: string | null, ) { - // Bail when disconnected — not worth triggering a reconnect for ephemeral typing events. + // Disconnected: not worth triggering a reconnect for ephemeral typing. if (this.wsId === null) { return; } @@ -325,11 +329,11 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { + // 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS' + // other consumers (unread tracking, cache merges) must never see + // summary overlays. return this.subscribe( { - // 39005 rides only this window-store subscription — not - // CHANNEL_EVENT_KINDS, whose other consumers (unread tracking, - // timeline-cache merges) must never see summary overlays. kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY], "#h": [channelId], limit: 1000, @@ -340,10 +344,9 @@ export class RelayClient { } /** - * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel. - * Used by HuddleIndicator to detect active huddles without being drowned - * out by regular channel messages in the generic subscription window. - * Includes both historical (last 10) and live events. + * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel, + * so HuddleIndicator detects active huddles without being drowned out by + * regular channel messages. Includes the last 10 historical events. */ async subscribeToHuddleEvents( channelId: string, @@ -425,12 +428,26 @@ export class RelayClient { } async preconnect() { - // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. A manual - // reconnect also bypasses the current delay once; ordinary operations do - // not, so background traffic cannot continuously defeat backoff. + // Explicit re-engagement (reconnect card / community switch): clears the + // terminal latch and AUTH rejection streak, and bypasses backoff once. this.terminal = false; + this.authOkTracker.reset(); this.keepAliveRequested = true; + await this.connectBypassingBackoff(); + } + + /** + * Environment-driven resume (online/focus/visibility): bypasses a pending + * backoff timer but preserves the terminal latch and AUTH rejection streak + * — only `preconnect()` clears those, so resume events during repeated + * AUTH rejection cannot defeat the consecutive-rejection cap. + */ + async resumeReconnect() { + if (this.terminal) return; + await this.connectBypassingBackoff(); + } + + private async connectBypassingBackoff() { if (this.reconnectTimeout !== null) { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -448,7 +465,6 @@ export class RelayClient { subscribeToReconnects(listener: () => void) { this.reconnectListeners.add(listener); - return () => { this.reconnectListeners.delete(listener); }; @@ -460,8 +476,8 @@ export class RelayClient { } /** - * Subscribe to connection-state transitions. The listener is invoked - * immediately with the current state so callers don't need a separate + * Subscribe to connection-state transitions. The listener fires + * immediately with the current state, so callers need no separate * `getConnectionState()` call to seed their UI. */ subscribeToConnectionState(listener: (state: ConnectionState) => void) { @@ -470,11 +486,10 @@ export class RelayClient { private async ensureConnected() { if (shouldRefuseConnect({ terminal: this.terminal })) { - // Session is terminal (e.g. relay rejected auth). Refuse to connect - // until an explicit re-engagement (disconnect()/preconnect()) clears - // the flag. Without this, the reconnect timer's catch handler — and - // the retry wrappers in publishEvent / sendRawWithReconnectRetry — - // would race the terminal "disconnected" state back to "reconnecting". + // Terminal (e.g. relay rejected auth): refuse until disconnect() or + // preconnect() clears the latch, else the reconnect-timer catch and + // the publish/subscribe retry wrappers would race the terminal + // "disconnected" state back to "reconnecting". throw new Error("Relay session is terminal; cannot reconnect."); } @@ -494,7 +509,7 @@ export class RelayClient { // The reconnect coordinator owns outage pacing. Query, publish, and // subscription callers must wait for its scheduled attempt instead of // clearing the timer and creating an immediate reconnect storm. - return this.waitForScheduledReconnect(); + return this.reconnectWaiters.wait(); } const connectPromise = this.connect(); @@ -670,7 +685,6 @@ export class RelayClient { error, fallbackMessage, ); - try { await this.ensureConnected(); await this.sendRaw(payload); @@ -749,12 +763,7 @@ export class RelayClient { this.resetConnection(new Error("Relay connection closed.")); return; } - if ( - typeof message === "object" && - message !== null && - "type" in message && - message.type === "Error" - ) { + if (isWebSocketError(message)) { this.resetConnection(new Error("Relay connection errored.")); return; } @@ -819,8 +828,7 @@ export class RelayClient { if (type === "NOTICE" && typeof rest[0] === "string") { const notice: string = rest[0]; - // Relay back-pressure signal — activate the gate so pending operations - // back off until the window expires. + // Relay back-pressure — arm the gate until the window expires. if (notice.startsWith("rate-limited:")) { activateRateLimit(parseRateLimitHint(notice)); } @@ -892,12 +900,14 @@ export class RelayClient { const authRequest = this.authRequest; this.authRequest = null; - if (success) { + // Decision table lives in relayAuthPolicy.ts. + const decision = this.authOkTracker.record(success, message); + if (decision === "authenticated") { authRequest.resolve(); } else { const error = new Error(message || "Relay authentication rejected."); authRequest.reject(error); - this.resetConnection(error, { reconnect: false }); + this.resetConnection(error, { reconnect: decision === "retry" }); } return; @@ -919,13 +929,7 @@ export class RelayClient { } private hasLiveSubscriptions() { - for (const subscription of this.subscriptions.values()) { - if (subscription.mode === "live") { - return true; - } - } - - return false; + return [...this.subscriptions.values()].some((s) => s.mode === "live"); } private async replayLiveSubscriptions() { @@ -948,13 +952,6 @@ export class RelayClient { } } - private waitForScheduledReconnect(): Promise { - if (this.reconnectTimeout === null) { - return this.ensureConnected(); - } - return this.reconnectWaiters.wait(); - } - private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -968,9 +965,8 @@ export class RelayClient { return; } - // Apply ±25% jitter so a fleet of clients reconnecting simultaneously - // spreads their AUTH storms across a 50% window instead of all hitting - // the relay at the same instant. + // ±25% jitter spreads a fleet's AUTH storms across a 50% window instead + // of hitting the relay at the same instant. const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5); const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS); this.reconnectDelayMs = Math.min( @@ -1031,9 +1027,13 @@ export class RelayClient { if (options?.reconnect === false) { this.terminal = true; this.connectionStateEmitter.set("disconnected"); - } else if (this.connectionStateEmitter.get() !== "stalled") { - // Stall is a stronger signal than a generic drop; keep it until the - // reconnect timer transitions us back to "reconnecting" in connect(). + } else if ( + // A late retry failure racing a terminal latch must not paint + // "reconnecting" over the terminal "disconnected" state; stall is a + // stronger signal than a generic drop and is kept until reconnect. + !this.terminal && + this.connectionStateEmitter.get() !== "stalled" + ) { this.connectionStateEmitter.set("reconnecting"); } diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs index 715df77f6..2e5cad4b7 100644 --- a/desktop/src/shared/api/relayClosedPolicy.test.mjs +++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs @@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => { for (const message of [ "restricted: not a channel member", "restricted: channel access revoked", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", @@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => { } }); +test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => { + // A REQ that lands before the AUTH handshake completes gets CLOSED with + // auth-required. Deleting the subscription would silently freeze the + // channel while the connection state still reads "connected" — the sub + // must survive and be retried after backoff. + assert.equal( + classifyRelayClosed("auth-required: not authenticated"), + "retryable", + ); + assert.equal( + classifyRelayClosed("auth-required: authenticate before subscribing"), + "retryable", + ); +}); + // ── Subscription-survival semantics ────────────────────────────────────────── // These replace the removed isRetryableRelayClosed wrapper tests. // rate-limited must not delete the subscription; terminal must. @@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => { for (const message of [ "restricted: not a channel member", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts index 8a39e8a4d..9e747a2df 100644 --- a/desktop/src/shared/api/relayClosedPolicy.ts +++ b/desktop/src/shared/api/relayClosedPolicy.ts @@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass { if (normalized.startsWith("rate-limited:")) { return "rate-limited"; } + // `auth-required:` is deliberately retryable, NOT terminal: it occurs + // transiently when a REQ races the AUTH handshake after a reconnect. The + // backoff retry re-sends the REQ once the session is authenticated. A + // session that is genuinely unauthenticated latches `terminal` at the + // connection level (AUTH OK=false), so this cannot loop forever. if ( normalized.startsWith("restricted:") || - normalized.startsWith("auth-required:") || normalized.startsWith("blocked:") || normalized.startsWith("invalid:") || normalized.startsWith("pow:") || diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index 00d8e412b..780ad3516 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean { data.code === 1012 ); } + +/** Whether a WS-layer message is the plugin's `Error` frame. */ +export function isWebSocketError(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Error" + ); +} diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs new file mode 100644 index 000000000..0430beb0b --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RESUME_TRIGGER_MIN_INTERVAL_MS, + shouldTriggerResumeReconnect, +} from "./relayResumeTriggerPolicy.ts"; + +const base = Object.freeze({ + connectionState: "reconnecting", + lastAttemptAt: 0, + now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1, +}); + +test("reconnecting + interval elapsed triggers", () => { + assert.equal(shouldTriggerResumeReconnect({ ...base }), true); +}); + +test("stalled + interval elapsed triggers", () => { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }), + true, + ); +}); + +test("healthy and terminal states never trigger", () => { + for (const connectionState of [ + "idle", + "connecting", + "connected", + "disconnected", + ]) { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState }), + false, + connectionState, + ); + } +}); + +test("burst of triggers within the rate window fires once", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1, + }), + false, + ); + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS, + }), + true, + ); +}); + +test("custom interval override is honoured", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 0, + now: 10, + minIntervalMs: 5, + }), + true, + ); +}); diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.ts b/desktop/src/shared/api/relayResumeTriggerPolicy.ts new file mode 100644 index 000000000..bb2ec8048 --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.ts @@ -0,0 +1,39 @@ +/** + * Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit). + * + * The exponential-backoff timer is the only thing driving recovery after an + * outage — and WKWebView throttles JS timers in occluded/background windows, + * so at max backoff (30s) a scheduled attempt may not fire until the user + * focuses the window. These triggers short-circuit the wait the moment the + * environment signals recovery: network `online`, window focus, and + * visibility becoming visible. `preconnect()` already clears the pending + * backoff timer, so a trigger converts "wait up to 30s (or forever, if + * throttled)" into "reconnect now". + */ +import type { ConnectionState } from "@/shared/api/relayClientShared"; + +/** Min ms between trigger-driven preconnect attempts. */ +export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000; + +export function shouldTriggerResumeReconnect(inputs: { + connectionState: ConnectionState; + lastAttemptAt: number; + now: number; + minIntervalMs?: number; +}): boolean { + const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS; + + // Only degraded-but-recoverable states. `disconnected` is the terminal + // latch — explicit user re-engagement owns that path, and `idle` / + // `connecting` / `connected` need no help. + if ( + inputs.connectionState !== "reconnecting" && + inputs.connectionState !== "stalled" + ) { + return false; + } + + // Rate-limit: focus/online events arrive in bursts (e.g. wake fires all + // three); one attempt per window is enough. + return inputs.now - inputs.lastAttemptAt >= minInterval; +} diff --git a/desktop/src/shared/api/restartDiff.ts b/desktop/src/shared/api/restartDiff.ts new file mode 100644 index 000000000..1ea46f488 --- /dev/null +++ b/desktop/src/shared/api/restartDiff.ts @@ -0,0 +1,34 @@ +/** + * Wire types for the restart-required config diff. + * + * These are camelCase / TS-idiomatic mirrors of the Rust `RestartDiffEntry` + * serialized shape. The `field` is a dotted path derived from serde field + * names (e.g. `"model"`, `"env.FOO"`); display labels are produced by + * humanising the path — there is no per-field label map. + */ + +/** A JSON-compatible value. Arrays are atomic leaves in the diff. */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * One change in a restart diff. The `kind` discriminant is a closed set of + * five values; unknown `field` paths must render gracefully in the UI. + */ +export type RestartChange = + | { kind: "value"; before: JsonValue; after: JsonValue } + | { kind: "text"; before_chars: number | null; after_chars: number | null } + | { kind: "masked"; before: string | null; after: string | null } + | { kind: "added" } + | { kind: "removed" }; + +export type RestartDiffEntry = { + /** Dotted path derived from serde field names, e.g. "model", "env.FOO". */ + field: string; + change: RestartChange; +}; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 62a262e4f..c44fd3b1c 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -118,6 +118,8 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; + +import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; export type RawManagedAgent = { pubkey: string; name: string; @@ -143,6 +145,7 @@ export type RawManagedAgent = { persona_out_of_date: boolean; persona_orphaned: boolean; needs_restart: boolean; + restart_diff?: RawRestartDiffEntry[]; env_vars?: Record; status: ManagedAgent["status"]; pid: number | null; @@ -158,8 +161,7 @@ export type RawManagedAgent = { auto_restart_on_config_change?: boolean; backend: ManagedAgentBackend; backend_agent_id: string | null; - // Optional: pre-feature mock fixtures may omit these. Mapped to - // `"owner-only"` / `[]` in `fromRawManagedAgent`. + // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. respond_to?: ManagedAgent["respondTo"]; respond_to_allowlist?: string[]; }; @@ -707,11 +709,11 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { avatarUrl: agent.avatar_url ?? null, model: agent.model, modelSource: agent.model_source ?? null, - // Fallbacks for pre-feature mocks/fixtures. Real records always carry them. provider: agent.provider ?? null, personaOutOfDate: agent.persona_out_of_date ?? false, personaOrphaned: agent.persona_orphaned ?? false, needsRestart: agent.needs_restart ?? false, + restartDiff: agent.restart_diff ?? [], envVars: agent.env_vars ?? {}, status: agent.status, pid: agent.pid, @@ -727,8 +729,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, backend: agent.backend, backendAgentId: agent.backend_agent_id, - // Fallbacks for pre-feature mocks/fixtures that don't carry these fields. - // Real agent records always include them (defaulted server-side). respondTo: agent.respond_to ?? "owner-only", respondToAllowlist: agent.respond_to_allowlist ?? [], }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index fd2c71bce..78f5d1aa3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -304,6 +304,8 @@ export type ManagedAgentBackend = | { type: "local" } | { type: "provider"; id: string; config: Record }; +import type { RestartDiffEntry } from "./restartDiff"; +export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff"; export type ManagedAgent = { pubkey: string; name: string; @@ -357,6 +359,8 @@ export type ManagedAgent = { * Always `false` for stopped agents. */ needsRestart: boolean; + /** Non-empty iff `needsRestart` is true. Empty when Rust omits the field. */ + restartDiff: RestartDiffEntry[]; /** Per-agent env vars. Layered on top of persona envVars. */ envVars: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -382,11 +386,7 @@ export type ManagedAgent = { respondToAllowlist: string[]; }; -/** - * Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag. - * `"nobody"` is supported by the harness but not surfaced through this API — - * it's a heartbeat-only mode without a meaningful GUI use case. - */ +/** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; export type BackendProviderCandidate = { diff --git a/desktop/src/shared/api/useRelayAutoHeal.ts b/desktop/src/shared/api/useRelayAutoHeal.ts index 11909228b..429dcc502 100644 --- a/desktop/src/shared/api/useRelayAutoHeal.ts +++ b/desktop/src/shared/api/useRelayAutoHeal.ts @@ -2,12 +2,12 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { ConnectionState } from "@/shared/api/relayClientShared"; -import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; +import { relayClient } from "@/shared/api/relayClient"; import { isRelayConnectionDegraded, - useRelayConnection, -} from "@/shared/api/useRelayConnection"; + type ConnectionState, +} from "@/shared/api/relayClientShared"; +import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { isRateLimited, waitForRateLimit, @@ -100,8 +100,6 @@ export class RelayAutoHealScheduler { */ export function useRelayAutoHeal(): void { const queryClient = useQueryClient(); - const connectionState = useRelayConnection(); - const prevConnectionStateRef = React.useRef(connectionState); const schedulerRef = React.useRef(null); if (schedulerRef.current === null) { @@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void { } React.useEffect(() => { + // Observe the RAW connection-state emitter, not the 2s-debounced + // useRelayConnection() hook. A sub-2s flap never surfaces through the + // debounced hook (its degraded report is cancelled by the recovery), yet + // resetConnection() already rejected every in-flight query — the heal + // must still fire or errored panes persist until a manual reconnect. + let prev: ConnectionState | null = null; + const unsubscribe = relayClient.subscribeToConnectionState((next) => { + if (prev !== null) { + schedulerRef.current?.onTransition(prev, next); + } + prev = next; + }); + return () => { + unsubscribe(); schedulerRef.current?.dispose(); }; }, []); - - React.useEffect(() => { - const prev = prevConnectionStateRef.current; - prevConnectionStateRef.current = connectionState; - schedulerRef.current?.onTransition(prev, connectionState); - }, [connectionState]); } diff --git a/desktop/src/shared/api/useRelayResumeTriggers.ts b/desktop/src/shared/api/useRelayResumeTriggers.ts new file mode 100644 index 000000000..abb2e5d49 --- /dev/null +++ b/desktop/src/shared/api/useRelayResumeTriggers.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy"; + +/** + * Event-driven reconnect triggers: network `online`, window `focus`, and + * visibility→visible each attempt an immediate `resumeReconnect()` when the + * relay session is degraded (reconnecting/stalled), rate-limited by + * `RESUME_TRIGGER_MIN_INTERVAL_MS`. + * + * Rationale (CMD+R gap audit G1): without these, recovery rides solely on + * the backoff timer, which WKWebView throttles while the window is occluded + * or the system was asleep. The moment a user focuses the window to hit + * CMD+R *is* a focus event — this fires the reconnect first. + * + * Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the + * pending backoff timer but must preserve the terminal latch and the AUTH + * rejection streak. Otherwise a focus/online event arriving during repeated + * AUTH rejection would reset the consecutive-rejection cap and the session + * could retry indefinitely instead of surfacing `disconnected`. The + * terminal state stays user-owned via the reconnect card. + */ +export function useRelayResumeTriggers(): void { + React.useEffect(() => { + let lastAttemptAt = -Infinity; + + const attempt = () => { + const now = Date.now(); + if ( + !shouldTriggerResumeReconnect({ + connectionState: relayClient.getConnectionState(), + lastAttemptAt, + now, + }) + ) { + return; + } + lastAttemptAt = now; + // resumeReconnect() clears any pending backoff timer and connects now, + // preserving terminal/AUTH-streak state. Failures re-arm the normal + // backoff loop; nothing to handle here. + void relayClient.resumeReconnect().catch(() => {}); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") attempt(); + }; + + window.addEventListener("online", attempt); + window.addEventListener("focus", attempt); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + window.removeEventListener("online", attempt); + window.removeEventListener("focus", attempt); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, []); +} diff --git a/desktop/src/shared/lib/localStorageQuota.test.mjs b/desktop/src/shared/lib/localStorageQuota.test.mjs index 64e351113..9b5061e55 100644 --- a/desktop/src/shared/lib/localStorageQuota.test.mjs +++ b/desktop/src/shared/lib/localStorageQuota.test.mjs @@ -34,12 +34,13 @@ function install(ls) { } test("startup recovery removes disposable caches but preserves user state", () => { - const ls = makeQuotaLocalStorage({ maxEntries: 5 }); + const ls = makeQuotaLocalStorage({ maxEntries: 6 }); install(ls); ls.store.set("buzz-channel-messages.v1:relay:chan", "big"); ls.store.set("buzz-channels.v1:relay", "big"); ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small"); ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small"); + ls.store.set("buzz-user-labels.v1:relay", "small"); ls.store.set("buzz-communities", "keep"); recoverLocalStorageQuotaOnStartup(); @@ -51,6 +52,7 @@ test("startup recovery removes disposable caches but preserves user state", () = ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"), null, ); + assert.equal(ls.getItem("buzz-user-labels.v1:relay"), null); assert.equal(ls.getItem("buzz-communities"), "keep"); assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1"); }); @@ -200,3 +202,40 @@ test("returns false when eviction frees nothing", () => { assert.equal(ls.getItem("k"), null); assert.equal(ls.getItem("buzz-workspaces"), "keep"); }); + +test("buzz-observed-unread.v1: prefix participates in LRU eviction and durable state survives", () => { + // Sentinel: fails if buzz-observed-unread.v1: is removed from PURE_CACHE_KEY_PREFIXES — + // the bucket becomes invisible to LRU and the wrong entry is evicted instead. + const ls = makeQuotaLocalStorage({ maxEntries: 20 }); + install(ls); + ls.store.set("buzz-communities", "keep"); + + const snapshot = (updatedAt) => + JSON.stringify({ updatedAt, payload: "x".repeat(400_000) }); + const observedKey = "buzz-observed-unread.v1:wss://relay.example.com:pk1"; + const olderKey = "buzz-channel-messages.v1:relay:older"; + const newestKey = "buzz-channel-messages.v1:relay:newest"; + + // Seed observed-unread (oldest updatedAt=1) and a sibling channel-messages entry (updatedAt=2). + assert.equal(setLocalStorageItemWithRecovery(observedKey, snapshot(1)), true); + assert.equal(setLocalStorageItemWithRecovery(olderKey, snapshot(2)), true); + + // Writing a third pure-cache entry (updatedAt=3) pushes the total above the 2 MiB budget. + // The observed-unread entry must be evicted first (oldest LRU); the sibling survives. + assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true); + assert.equal( + ls.getItem(observedKey), + null, + "observed-unread bucket must be evicted as the oldest LRU pure-cache entry", + ); + assert.notEqual( + ls.getItem(olderKey), + null, + "channel-messages bucket with newer updatedAt must survive", + ); + assert.equal( + ls.getItem("buzz-communities"), + "keep", + "durable state must survive", + ); +}); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 538003ef6..189ff09fb 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -10,8 +10,10 @@ const PURE_CACHE_KEY_PREFIXES = [ "buzz-channel-messages.v1:", "buzz-channels.v1:", + "buzz-observed-unread.v1:", "buzz-sidebar-skeleton-shape.v1:", "buzz-timeline-skeleton-shape.v1:", + "buzz-user-labels.v1:", ]; const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1"; diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 704f6e542..0d5a10321 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -10,6 +10,7 @@ @import "./globals/skeleton.css"; @import "./globals/spoilers.css"; @import "./globals/components.css"; +@import "./globals/terminal.css"; @import "./globals/utilities.css"; @import "./globals/media-controls.css"; @import "./globals/avatar-framing.css"; @@ -30,3 +31,6 @@ Must stay below every `@import`: CSS requires `@import` to precede other at-rules, so placing this above them silently drops the rest of the sheet. */ @custom-variant hover (&:hover); +/* The app persists its selected theme on the root `.dark` class. Use that + class, rather than the system preference, for every `dark:` utility. */ +@custom-variant dark (&:where(.dark, .dark *)); diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index fe4001a87..27f641578 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -751,119 +751,3 @@ } } } - -@layer components { - .buzz-terminal-substrate { - background: var(--buzz-terminal-background, #101014); - color: var(--buzz-terminal-foreground, #e8e8ec); - display: flex; - flex-direction: column; - font-family: "JetBrains Mono", monospace; - font-variant-ligatures: none; - inset: 0; - position: absolute; - user-select: none; - z-index: 0; - } - - .buzz-terminal-contract-bar { - align-items: stretch; - border-bottom: 1px solid hsl(var(--border)); - display: flex; - flex: 0 0 32px; - justify-content: space-between; - min-width: 0; - } - - .buzz-terminal-tabs, - .buzz-terminal-readout { - align-items: center; - display: flex; - min-width: 0; - } - - .buzz-terminal-tab, - .buzz-terminal-tab-select, - .buzz-terminal-close, - .buzz-terminal-new-tab { - align-items: center; - background: transparent; - border: 0; - color: inherit; - display: flex; - font: inherit; - gap: 8px; - height: 100%; - opacity: 0.62; - padding: 0 10px; - position: relative; - } - - .buzz-terminal-tab-active { - opacity: 1; - } - - .buzz-terminal-tab-active::after { - background: hsl(var(--buzz-selected-accent)); - bottom: 0; - content: ""; - height: 1px; - left: 0; - position: absolute; - right: 0; - } - - .buzz-terminal-designator, - .buzz-terminal-readout { - font-size: 0.5625rem; - letter-spacing: 0.12em; - text-transform: uppercase; - } - - .buzz-terminal-close { - opacity: 0; - } - - .buzz-terminal-tab:hover .buzz-terminal-close, - .buzz-terminal-close:focus { - opacity: 1; - } - - .buzz-terminal-readout { - gap: 14px; - padding: 0 12px; - white-space: nowrap; - } - - .buzz-terminal-viewport, - .buzz-terminal-viewport canvas { - height: 100%; - min-height: 0; - width: 100%; - } - - .buzz-terminal-viewport { - overflow: hidden; - position: relative; - } - - .buzz-terminal-viewport canvas { - display: block; - } - - .buzz-terminal-welcome { - inset: 0; - pointer-events: none; - position: absolute; - z-index: 1; - } - - .buzz-terminal-input { - height: 1px; - left: -10000px; - opacity: 0; - position: absolute; - top: 0; - width: 1px; - } -} diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css new file mode 100644 index 000000000..26b5f42ba --- /dev/null +++ b/desktop/src/shared/styles/globals/terminal.css @@ -0,0 +1,302 @@ +@layer components { + .buzz-terminal-substrate { + background: var(--buzz-terminal-background, #101014); + color: var(--buzz-terminal-foreground, #e8e8ec); + display: flex; + flex-direction: column; + font-family: "JetBrains Mono", monospace; + font-variant-ligatures: none; + inset: 0; + position: absolute; + user-select: none; + z-index: 0; + } + + .buzz-terminal-contract-bar { + align-items: center; + background: hsl(var(--secondary)); + border-bottom: 1px solid hsl(var(--border)); + color: hsl(var(--secondary-foreground)); + display: flex; + flex: 0 0 40px; + font-family: inherit; + font-variant-ligatures: normal; + gap: 8px; + justify-content: space-between; + min-width: 0; + padding: 4px 1.25rem; + } + + .buzz-terminal-tabs, + .buzz-terminal-readout { + align-items: center; + display: flex; + min-width: 0; + } + + .buzz-terminal-tab, + .buzz-terminal-tab-select, + .buzz-terminal-close, + .buzz-terminal-new-tab { + align-items: center; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + display: flex; + font: inherit; + font-size: 0.75rem; + font-weight: 500; + gap: 6px; + height: 30px; + padding: 0 9px; + position: relative; + } + + .buzz-terminal-tabs { + flex: 1 1 auto; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + + .buzz-terminal-tabs::-webkit-scrollbar { + display: none; + } + + .buzz-terminal-readout { + background: hsl(var(--secondary)); + flex: 0 0 auto; + gap: 4px; + padding: 0; + position: relative; + z-index: 1; + } + + .buzz-terminal-tab { + background: hsl(var(--background)); + border-radius: 4px; + flex: 0 0 auto; + padding: 0; + } + + .buzz-terminal-tab-active { + background: hsl(var(--background)); + } + + .buzz-terminal-tab:hover, + .buzz-terminal-tab:focus-within { + background: hsl(var(--foreground) / 0.06); + } + + .buzz-terminal-tab-select { + border-radius: inherit; + max-width: 12rem; + min-width: 0; + padding-left: 32px; + } + + .buzz-terminal-tab-active .buzz-terminal-tab-select { + color: hsl(var(--foreground)); + } + + .buzz-terminal-new-tab:hover { + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); + } + + .buzz-terminal-new-tab { + align-items: center; + background: hsl(var(--background)); + border-radius: 4px; + height: 30px; + justify-content: center; + padding: 7px; + width: 30px; + } + + .buzz-terminal-new-tab svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .buzz-terminal-designator { + align-items: center; + display: inline-flex; + font-size: 0.75rem; + gap: 2px; + letter-spacing: 0; + } + + .buzz-terminal-designator svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-close { + height: 16px; + left: 8px; + opacity: 0; + padding: 0; + pointer-events: none; + position: absolute; + width: 16px; + z-index: 1; + } + + .buzz-terminal-tab:hover .buzz-terminal-close, + .buzz-terminal-tab:focus-within .buzz-terminal-close { + color: hsl(var(--foreground)); + opacity: 1; + pointer-events: auto; + } + + .buzz-terminal-close svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-readout { + white-space: nowrap; + } + + .buzz-terminal-viewport, + .buzz-terminal-viewport canvas { + height: 100%; + min-height: 0; + width: 100%; + } + + .buzz-terminal-viewport { + overflow: hidden; + position: relative; + } + + .buzz-terminal-viewport canvas { + display: block; + } + + .buzz-terminal-welcome { + inset: 0; + pointer-events: none; + position: absolute; + z-index: 1; + } + + .buzz-terminal-input { + height: 1px; + left: -10000px; + opacity: 0; + position: absolute; + top: 0; + width: 1px; + } +} + +@layer components { + .buzz-terminal-dock-host:has(.buzz-terminal-substrate) { + align-items: flex-end; + display: flex; + flex: 0 0 auto; + min-height: 0; + overflow: hidden; + transition: + flex-grow 180ms ease, + flex-basis 180ms ease; + } + + .buzz-content-primary { + transition: + flex-grow 180ms ease, + flex-basis 180ms ease; + } + + .buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) { + flex: 1 1 auto; + } + + .buzz-content-primary:has( + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] + ) { + flex: 0 1 0%; + min-height: 0; + } + + .buzz-terminal-substrate { + border-top: 1px solid hsl(var(--border)); + inset: auto; + min-height: 180px; + opacity: 1; + position: relative; + transform: translateY(0); + transition: + height 180ms ease, + transform 180ms ease; + width: 100%; + z-index: 20; + } + + .buzz-terminal-substrate[data-terminal-resizing="true"] { + transition: none; + } + + .buzz-terminal-substrate[data-terminal-visible="false"] { + height: 0 !important; + min-height: 0; + pointer-events: none; + transform: translateY(16px); + } + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] { + flex: 1 1 auto; + height: 100%; + } + + .buzz-terminal-resize-handle { + cursor: ns-resize; + height: 5px; + left: 0; + position: absolute; + right: 0; + top: -3px; + z-index: 2; + } + + .buzz-terminal-window-action { + align-items: center; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + display: inline-flex; + height: 30px; + justify-content: center; + width: 30px; + } + + .buzz-terminal-window-action:hover, + .buzz-terminal-window-action:focus-visible { + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); + } + + .buzz-terminal-window-action svg { + height: 16px; + width: 16px; + } + + @media (prefers-reduced-motion: reduce) { + .buzz-content-primary, + .buzz-terminal-dock-host:has(.buzz-terminal-substrate), + .buzz-terminal-substrate { + transition: none; + } + } +} diff --git a/desktop/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index 153e61478..15c054073 100644 --- a/desktop/src/shared/ui/UnreadPill.tsx +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -1,9 +1,12 @@ import { ArrowDown, ArrowUp } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; const UNREAD_PILL_CLASS = "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"; +const PRIMARY_UNREAD_PILL_CLASS = + "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4"; export function unreadCountLabel(count: number) { return `${count} new message${count === 1 ? "" : "s"}`; @@ -11,11 +14,13 @@ export function unreadCountLabel(count: number) { export function UnreadPill({ direction, + emphasis = "default", label, onClick, testId, }: { direction: "up" | "down"; + emphasis?: "default" | "primary"; label: string; onClick: () => void; testId: string; @@ -23,12 +28,14 @@ export function UnreadPill({ const Arrow = direction === "up" ? ArrowUp : ArrowDown; return (