diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 1ba64765f..e40fce5c3 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, - "version": "0.5.4", - "base_sha": "6de85fe31d781122756aecf954bae7d357a56b9a", - "previous_tag": "desktop-v0.5.3", - "tag": "desktop-v0.5.4", - "commit_count": 40 + "version": "0.5.5", + "base_sha": "4a2305170eef565bf1836e2859247e67c030f8af", + "previous_tag": "desktop-v0.5.4", + "tag": "desktop-v0.5.5", + "commit_count": 41 } diff --git a/AGENTS.md b/AGENTS.md index 7cd43061b..571871c3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -507,6 +507,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/CHANGELOG.md b/CHANGELOG.md index e30941a35..962b2ce29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## v0.5.5 + +### Desktop and shared changes + +- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) +- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) +- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) +- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) +- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) +- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) +- fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) +- fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) +- fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) +- Polish Huddle voice controls ([#4694](https://github.com/block/buzz/pull/4694)) ([`ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d`](https://github.com/block/buzz/commit/ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d)) +- fix(local-archive): default both archive settings to enabled ([#4750](https://github.com/block/buzz/pull/4750)) ([`5179726737108a4a91076d262c30a53d4a7237e9`](https://github.com/block/buzz/commit/5179726737108a4a91076d262c30a53d4a7237e9)) +- fix(desktop): close reconnect gaps that previously required CMD+R ([#4737](https://github.com/block/buzz/pull/4737)) ([`e5efd047050f5e2a64fe6cd9e3faed1685b03f5c`](https://github.com/block/buzz/commit/e5efd047050f5e2a64fe6cd9e3faed1685b03f5c)) +- Dock Buzz Term within channel workspace ([#4724](https://github.com/block/buzz/pull/4724)) ([`cb4a73e17d0760eba6c3c01811da07e1d3a6b85e`](https://github.com/block/buzz/commit/cb4a73e17d0760eba6c3c01811da07e1d3a6b85e)) +- fix(agents): canonicalize stale persona harness pins ([#4631](https://github.com/block/buzz/pull/4631)) ([`0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8`](https://github.com/block/buzz/commit/0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8)) +- Refine community invite links ([#4734](https://github.com/block/buzz/pull/4734)) ([`e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31`](https://github.com/block/buzz/commit/e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31)) +- feat(desktop): persist sidebar observed-unread across webview reload ([#3976](https://github.com/block/buzz/pull/3976)) ([`0afeac8a7c173fd3ede8a22e27919e63161bf07c`](https://github.com/block/buzz/commit/0afeac8a7c173fd3ede8a22e27919e63161bf07c)) +- feat(desktop): surface config diff in restart-required badge ([#3637](https://github.com/block/buzz/pull/3637)) ([`f86dfc58838a272a5d0504ebf216a79b7288f027`](https://github.com/block/buzz/commit/f86dfc58838a272a5d0504ebf216a79b7288f027)) +- Polish sidebar unread hierarchy ([#4573](https://github.com/block/buzz/pull/4573)) ([`540b58920cef205b838da8be8442aae62bceaaa5`](https://github.com/block/buzz/commit/540b58920cef205b838da8be8442aae62bceaaa5)) +- fix(desktop): show cached display names on startup ([#3317](https://github.com/block/buzz/pull/3317)) ([`d0d4acd4fa02893ad2460b447d7e13da00506be3`](https://github.com/block/buzz/commit/d0d4acd4fa02893ad2460b447d7e13da00506be3)) +- Remove blur from Welcome composer guidance ([#4691](https://github.com/block/buzz/pull/4691)) ([`d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1`](https://github.com/block/buzz/commit/d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1)) +- Refine desktop timeline activity presentation ([#4582](https://github.com/block/buzz/pull/4582)) ([`a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a`](https://github.com/block/buzz/commit/a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a)) +- Defer desktop media uploads until send ([#4522](https://github.com/block/buzz/pull/4522)) ([`f18a9cb10688deaa3f618869170bfe9303c4be62`](https://github.com/block/buzz/commit/f18a9cb10688deaa3f618869170bfe9303c4be62)) +- fix(desktop): stop clipping focus ring on channel intro action cards (#2392) ([#4374](https://github.com/block/buzz/pull/4374)) ([`ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996`](https://github.com/block/buzz/commit/ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996)) +- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc23fdba94ce3537a194357ed17b197c`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c)) +- feat: ship Buzz Term ([#4347](https://github.com/block/buzz/pull/4347)) ([`631b05c883f58e9533e9038b4669ebdfb1d9cf27`](https://github.com/block/buzz/commit/631b05c883f58e9533e9038b4669ebdfb1d9cf27)) +- feat(mobile): sync per-group channel sorting ([#4231](https://github.com/block/buzz/pull/4231)) ([`b42b093613edfb7138acb0961a0ad9218b39691a`](https://github.com/block/buzz/commit/b42b093613edfb7138acb0961a0ad9218b39691a)) +- feat(desktop): redesign the Huddle experience ([#4281](https://github.com/block/buzz/pull/4281)) ([`b29c8cdaa456307ecdd63e565de4beb14402128e`](https://github.com/block/buzz/commit/b29c8cdaa456307ecdd63e565de4beb14402128e)) +- feat(agents): model-tuning parity in global Agent Defaults editor ([#4578](https://github.com/block/buzz/pull/4578)) ([`985cdcc6eac33ccd77bc50c26e22c701d07eda4e`](https://github.com/block/buzz/commit/985cdcc6eac33ccd77bc50c26e22c701d07eda4e)) +- Polish Share Compute settings ([#3735](https://github.com/block/buzz/pull/3735)) ([`027a74a61c8643a1d1086d3e8307fad89d7735f7`](https://github.com/block/buzz/commit/027a74a61c8643a1d1086d3e8307fad89d7735f7)) +- fix(reactions): wrap long popover names ([#3834](https://github.com/block/buzz/pull/3834)) ([`79815978483ef0ab78f7159c0add3492da6457a1`](https://github.com/block/buzz/commit/79815978483ef0ab78f7159c0add3492da6457a1)) +- fix(desktop): clarify inherited agent parallelism ([#4010](https://github.com/block/buzz/pull/4010)) ([`d4a4570b9769743899d97480b3bf482860b51d9c`](https://github.com/block/buzz/commit/d4a4570b9769743899d97480b3bf482860b51d9c)) +- feat(desktop): make onboarding model defaults skippable ([#3968](https://github.com/block/buzz/pull/3968)) ([`5c98932c59ee5344e9e8c14525c51f3de16ad2c2`](https://github.com/block/buzz/commit/5c98932c59ee5344e9e8c14525c51f3de16ad2c2)) + +### Other repository changes + +- fix(ci): make desktop cache test version agnostic ([#4791](https://github.com/block/buzz/pull/4791)) ([`383d9e1eafd569b44b9c835200dba69ef7cec9dc`](https://github.com/block/buzz/commit/383d9e1eafd569b44b9c835200dba69ef7cec9dc)) +- fix(mobile): stop oversized read-state retry loop ([#4595](https://github.com/block/buzz/pull/4595)) ([`7bee84da8267605ada939c4f911d90f1b0ff1a11`](https://github.com/block/buzz/commit/7bee84da8267605ada939c4f911d90f1b0ff1a11)) +- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c)) +- docs(acp): explain per-channel session model in base prompt ([#4729](https://github.com/block/buzz/pull/4729)) ([`56003ebf98c22367fb6357f295494e26efbd8ae6`](https://github.com/block/buzz/commit/56003ebf98c22367fb6357f295494e26efbd8ae6)) +- docs(nip-am): normative amendment — cache SHOULD/MUST + pricingIdentity + consumer cost guidance ([#4632](https://github.com/block/buzz/pull/4632)) ([`0542bc8b955756a62b4133aa70f84441d93616ee`](https://github.com/block/buzz/commit/0542bc8b955756a62b4133aa70f84441d93616ee)) +- feat(mobile): add channel scroll navigation ([#4239](https://github.com/block/buzz/pull/4239)) ([`d5da74e4e078a9551b9ce9e47e77cf9ed5840596`](https://github.com/block/buzz/commit/d5da74e4e078a9551b9ce9e47e77cf9ed5840596)) +- feat(mobile): bring channel menus to desktop parity ([#3940](https://github.com/block/buzz/pull/3940)) ([`ede8d22dd5b336f146e0a6d760fd9dff78a42613`](https://github.com/block/buzz/commit/ede8d22dd5b336f146e0a6d760fd9dff78a42613)) +- ci: add guarded desktop release cache prewarm ([#4575](https://github.com/block/buzz/pull/4575)) ([`e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0`](https://github.com/block/buzz/commit/e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0)) +- fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) + +[Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) + ## v0.5.4 ### Desktop and shared changes diff --git a/Justfile b/Justfile index d80341eca..c3d755ffe 100644 --- a/Justfile +++ b/Justfile @@ -213,24 +213,17 @@ desktop-terminal-performance-test: cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture # Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the observer_archive focused test twice with independently supplied +# Runs the auto-connect compiled-flag test twice with independently supplied # expected values; build.rs rerun-if-env-changed triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail cd desktop/src-tauri echo "=== Clean build (no flag) → expect false ===" - env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \ - -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flags set) → expect true ===" - BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture + echo "=== Internal build (flag set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d2498..1d85221f1 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. @@ -17,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | +| `buzz issues` | `create`, `get`, `list`, `status` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -24,6 +31,8 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 64edf68ee..ddc0330d9 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,8 +31,8 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, - SystemPromptTransport, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -867,13 +867,13 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown"; async fn resolve_new_session_channel_context( channel_info: &ChannelInfoResolver, channel_id: Uuid, -) -> (bool, Option) { +) -> (bool, Option, Option) { let Some(info) = channel_info.resolve(channel_id).await else { - return (true, None); + return (true, None, None); }; let is_dm = info.channel_type == "dm"; let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name); - (is_dm, title_channel) + (is_dm, title_channel, Some(info.channel_type)) } /// Create a new ACP session via `session_new_full()`, populate model capabilities @@ -888,6 +888,8 @@ async fn create_session_and_apply_model( agent_core: Option<&str>, agent_canvas: Option<&str>, channel_name: Option<&str>, + channel_id: Option, + channel_type: Option<&str>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -911,12 +913,18 @@ async fn create_session_and_apply_model( .session_title .as_deref() .map(|agent_name| compose_session_title(agent_name, channel_name)); + let mcp_servers = mcp_servers_with_git_origin( + &ctx.mcp_servers, + channel_id, + channel_type, + ctx.session_title.as_deref(), + ); let resp = agent .acp .session_new_full( &ctx.cwd, - ctx.mcp_servers.clone(), + mcp_servers, session_new_system_prompt( is_goose, agent.protocol_version, @@ -1019,6 +1027,34 @@ async fn create_session_and_apply_model( Ok(resp.session_id) } +fn mcp_servers_with_git_origin( + servers: &[McpServer], + channel_id: Option, + channel_type: Option<&str>, + agent_name: Option<&str>, +) -> Vec { + let mut servers = servers.to_vec(); + let origin = match (channel_id, channel_type) { + (Some(channel_id), Some("stream")) => Some(EnvVar { + name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(), + value: channel_id.to_string(), + }), + (Some(_), _) => agent_name + .filter(|name| !name.trim().is_empty()) + .map(|name| EnvVar { + name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(), + value: name.trim().to_string(), + }), + (None, _) => None, + }; + if let Some(origin) = origin { + for server in &mut servers { + server.env.push(origin.clone()); + } + } + servers +} + /// Send the appropriate ACP model-switch request with a timeout. /// /// On timeout or error, logs a warning and returns — the caller proceeds @@ -1519,14 +1555,15 @@ pub async fn run_prompt_task( // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; + let mut origin_channel_type: Option = None; if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); - let needs_title = is_new_channel_session && ctx.session_title.is_some(); - if needs_canvas || needs_title { - let (is_dm, resolved_channel) = + if is_new_channel_session { + let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(&ctx.channel_info, *cid).await; title_channel = resolved_channel; + origin_channel_type = resolved_channel_type; // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { @@ -1571,6 +1608,8 @@ pub async fn run_prompt_task( agent_core.as_deref(), agent_canvas.as_deref(), title_channel.as_deref(), + Some(*cid), + origin_channel_type.as_deref(), ) .await { @@ -1618,7 +1657,9 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None) + .await + { Ok(sid) => { tracing::info!( target: "pool::session", @@ -3989,6 +4030,50 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn test_mcp_server() -> McpServer { + McpServer { + name: "dev".into(), + command: "buzz-dev-mcp".into(), + args: vec![], + env: vec![], + } + } + + #[test] + fn public_session_forwards_channel_origin_to_mcp() { + let channel_id = Uuid::new_v4(); + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(channel_id), + Some("stream"), + None, + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string() + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME")); + } + + #[test] + fn private_session_forwards_agent_name_without_channel_id() { + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(Uuid::new_v4()), + Some("dm"), + Some("Builder"), + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder" + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID")); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -6833,12 +6918,14 @@ mod tests { let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); let (resolver, requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a stream channel is not a DM"); assert_eq!(title_channel.as_deref(), Some("buzz-dev")); + assert_eq!(channel_type.as_deref(), Some("stream")); assert_eq!(requests.load(Ordering::SeqCst), 1); - let (_, again) = resolve_new_session_channel_context(&resolver, id).await; + let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await; assert_eq!(again.as_deref(), Some("buzz-dev")); assert_eq!( requests.load(Ordering::SeqCst), @@ -6856,8 +6943,10 @@ mod tests { let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(is_dm); + assert_eq!(channel_type.as_deref(), Some("dm")); assert_eq!( title_channel, None, "a DM name must never reach the session title" @@ -6874,7 +6963,7 @@ mod tests { let response = channel_metadata_response(id, &[["t", "stream"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a nameless stream channel is still not a DM"); assert_eq!( title_channel, None, @@ -6894,10 +6983,11 @@ mod tests { let (resolver, requests, server) = counting_resolver(json!([])).await; - let (is_dm, title_channel) = + let (is_dm, title_channel, channel_type) = resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await; assert!(is_dm, "an undeterminable channel type must fail closed"); assert_eq!(title_channel, None, "unresolved channels get a bare title"); + assert_eq!(channel_type, None); assert_eq!( requests.load(Ordering::SeqCst), 2, diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index aa2a121c9..0aaa2da7e 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -7,12 +7,17 @@ //! //! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. //! - PKCE cache hit: returned from disk without a network round-trip. -//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the -//! caller degrades gracefully; no browser, no hang. +//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`. +//! +//! This helper never opens a browser. Callers choose whether to reject, degrade, +//! or start a separate interactive authentication flow. + +use std::sync::Arc; use reqwest::Client; use crate::{ + auth::TokenSource, config::{Config, Provider}, llm::build_token_source, types::AgentError, @@ -26,57 +31,22 @@ pub struct ModelEntry { pub name: String, } -/// Known Databricks AI Gateway v2 models — used as a fallback when the -/// `api/ai-gateway/v2/endpoints` call returns an empty list. +/// Known Databricks AI Gateway v2 models — used only when an authenticated +/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list. /// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`. pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &["databricks-gpt-5-5", "databricks-claude-opus-4-7"]; -/// Returns the discovery-failure fallback catalog for a Databricks provider. -/// -/// This is the list of models advertised by `session/new` when -/// `discover_databricks_models` returns an error (e.g., no token available). -/// -/// - `DatabricksV2` falls back to the configured model plus -/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for -/// AI Gateway v2 users. The configured model leads: without it a fallback -/// catalog can omit the very model the agent is running, leaving the picker -/// unable to represent the current selection. -/// - Legacy `Databricks` falls back to only the configured model — the -/// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the -/// `/serving-endpoints/{model}/invocations` API may not serve. -/// -/// Extracting this as a pure function makes the split testable without -/// spawning an async runtime or making network calls. -pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec { - // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here: - // normalize once, or the dedupe below misses and the picker lists the model - // twice (once padded, once from the known slate). - let configured_model = configured_model.trim(); - let configured = ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }; - match provider { - Provider::DatabricksV2 => { - let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1); - if !configured_model.is_empty() { - entries.push(configured); - } - entries.extend( - DATABRICKS_V2_KNOWN_MODELS - .iter() - .filter(|id| **id != configured_model) - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }), - ); - entries - } - Provider::Databricks => vec![configured], - _ => vec![configured], - } +const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; + +fn authenticated_empty_v2_catalog() -> Vec { + DATABRICKS_V2_KNOWN_MODELS + .iter() + .map(|id| ModelEntry { + id: id.to_string(), + name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"), + }) + .collect() } /// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves @@ -109,23 +79,47 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// /// Returns a non-empty `Vec` on success. Returns /// `Err(AgentError::LlmAuth)` when no token is available (no static token, -/// no PKCE cache) — callers should degrade gracefully rather than hanging. +/// no PKCE cache). The helper itself never starts interactive authentication. /// /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - let token_source = build_token_source(cfg)?; - let bearer = token_source.bearer_no_browser().await?; + discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await +} +async fn discover_databricks_models_with_token_source( + cfg: &Config, + token_source: Arc, +) -> Result, AgentError> { + let mut bearer = token_source.bearer_no_browser().await?; let http = Client::new(); let host = cfg.base_url.trim_end_matches('/'); + let mut refreshed = false; - match cfg.provider { - Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, - Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, - _ => Err(AgentError::InvalidParams( - "discover_databricks_models called for non-Databricks provider".into(), - )), + loop { + let result = match cfg.provider { + Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, + Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, + _ => { + return Err(AgentError::InvalidParams( + "discover_databricks_models called for non-Databricks provider".into(), + )); + } + }; + + match result { + Err(AgentError::LlmAuth(_)) if !refreshed => { + refreshed = true; + let fresh = token_source.refresh_now(&bearer).await?; + if fresh == bearer { + return Err(AgentError::LlmAuth( + "Databricks rejected the configured credential".into(), + )); + } + bearer = fresh; + } + result => return result, + } } } @@ -149,6 +143,11 @@ async fn fetch_v1_models( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks model discovery HTTP {status}" + ))); + } return Err(AgentError::Llm(format!( "Databricks model discovery HTTP {status}: {body}" ))); @@ -264,6 +263,11 @@ async fn fetch_v2_models( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks v2 model discovery HTTP {status}" + ))); + } return Err(AgentError::Llm(format!( "Databricks v2 model discovery HTTP {status}: {body}" ))); @@ -286,13 +290,7 @@ async fn fetch_v2_models( // Fall back to known-model list if the API returned nothing. if all_endpoints.is_empty() { - return Ok(DATABRICKS_V2_KNOWN_MODELS - .iter() - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }) - .collect()); + return Ok(authenticated_empty_v2_catalog()); } sort_v2_endpoints_newest_first(&mut all_endpoints); @@ -396,6 +394,77 @@ pub(crate) fn parse_v2_endpoints_page( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RefreshingTestTokenSource { + refreshes: AtomicUsize, + } + + #[async_trait] + impl TokenSource for RefreshingTestTokenSource { + async fn bearer(&self) -> Result { + Ok("rejected".into()) + } + + async fn refresh_now(&self, rejected: &str) -> Result { + assert_eq!(rejected, "rejected"); + self.refreshes.fetch_add(1, Ordering::SeqCst); + Ok("fresh".into()) + } + } + + #[tokio::test] + async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() { + use axum::{ + extract::Query, + http::{HeaderMap, StatusCode}, + routing::get, + Json, Router, + }; + use std::collections::HashMap; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_route = requests.clone(); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get( + move |headers: HeaderMap, Query(_query): Query>| { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + match headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + { + Some("Bearer fresh") => Ok(Json(serde_json::json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))), + _ => Err((StatusCode::UNAUTHORIZED, "rejected")), + } + } + }, + ), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let source = Arc::new(RefreshingTestTokenSource { + refreshes: AtomicUsize::new(0), + }); + let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host); + let models = discover_databricks_models_with_token_source(&cfg, source.clone()) + .await + .unwrap(); + + assert_eq!(models[0].id, "discovered-model"); + assert_eq!(source.refreshes.load(Ordering::SeqCst), 1); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } #[test] fn v1_parse_filters_ready_chat_endpoints() { @@ -574,6 +643,17 @@ mod tests { ); } + #[test] + fn authenticated_empty_v2_catalog_marks_fallback_provenance() { + let models = authenticated_empty_v2_catalog(); + let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); + + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS); + assert!(models.iter().all(|model| { + model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id) + })); + } + #[test] fn is_chat_capable_endpoint_keeps_unrecognised_names() { // Prefer including over silently dropping — an unknown family is kept. @@ -585,47 +665,4 @@ mod tests { assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); } - - #[test] - fn v2_discovery_failure_fallback_leads_with_configured_model() { - let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5"); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - - // The running model must be representable in the picker even when - // discovery failed, so it leads the fallback catalog. - assert_eq!(ids.first(), Some(&"databricks-claude-opus-5")); - for known in DATABRICKS_V2_KNOWN_MODELS { - assert!(ids.contains(known), "fallback must retain '{known}'"); - } - } - - #[test] - fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() { - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let occurrences = result.iter().filter(|m| m.id == configured).count(); - assert_eq!(occurrences, 1, "got: {result:?}"); - assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len()); - } - - #[test] - fn v2_discovery_failure_fallback_tolerates_blank_configured_model() { - for configured in ["", " "] { - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } - } - - #[test] - fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() { - // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an - // untrimmed comparison would list the model twice — once padded, once from - // the known slate. - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = - discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} ")); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 6cd7b6808..940bd2a9c 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -54,10 +54,10 @@ struct App { llm: Arc, sessions: Mutex>, /// Cached model catalog for Databricks providers. Populated lazily on the - /// first successful `session/new` discovery call. When discovery fails (e.g. - /// auth missing or a transient network error) the cell is intentionally left - /// empty so the next `session/new` call retries — a transient failure never - /// pins the degraded fallback catalog for the process lifetime. + /// first successful `session/new` discovery call. Failed discovery is never + /// cached: static-token authentication errors reject session creation, while + /// OAuth authentication and non-auth errors use the configured model for that + /// response and retry on the next session. models_cache: tokio::sync::OnceCell>, } @@ -135,6 +135,12 @@ pub fn run() -> Result<(), Box> { Ok(()) } +pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? + .interactive_login() + .await +} + /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the @@ -145,18 +151,7 @@ async fn auth_subcommand(args: &[String]) -> Result<(), Box { let host = std::env::var("DATABRICKS_HOST") .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; - let pkce = auth::PkceOAuthConfig { - discovery_url: format!( - "{}/oidc/.well-known/oauth-authorization-server", - host.trim_end_matches('/') - ), - client_id: "databricks-cli".into(), - scopes: vec!["all-apis".into(), "offline_access".into()], - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - let src = auth::PkceOAuthTokenSource::new(pkce)?; - src.interactive_login().await?; + authenticate_databricks(&host).await?; eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); Ok(()) } @@ -317,26 +312,27 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { /// /// Tries to use a previously-cached successful discovery result. If the cache is empty, /// runs `discover` and — on success — populates the cache for future calls. On failure -/// the cell is intentionally left empty so the next session retries; the provider-aware -/// fallback is returned for the immediate response only. +/// the error is returned and the cell is intentionally left empty so the next session retries. /// /// Extracted from `session_new` so that tests can drive this path with an injected /// discovery future without requiring a full `App` / transport stack. async fn resolve_models_catalog( cache: &tokio::sync::OnceCell>, - provider: crate::config::Provider, - model: &str, discover: impl std::future::Future, AgentError>>, -) -> Vec { - match cache.get_or_try_init(|| discover).await { - Ok(cached) => cached.clone(), - Err(e) => { - tracing::warn!( - "model catalog discovery failed: {e}; using fallback (will retry next session)" - ); - crate::catalog::discovery_failure_fallback(provider, model) - } - } +) -> Result, AgentError> { + cache.get_or_try_init(|| discover).await.cloned() +} + +/// Return the configured model as a one-entry catalog for this response. +/// +/// This value is never written to `models_cache`; failed discovery must be retried by +/// the next session rather than pinning degraded state for the process lifetime. +fn configured_model_fallback(model: &str) -> Vec { + let model = model.trim().to_string(); + vec![ModelEntry { + id: model.clone(), + name: model, + }] } async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { @@ -400,6 +396,50 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen } Arc::from(prompt) }; + // Resolve the model catalog before spawning MCP servers or registering a + // session. A configured static credential cannot recover interactively, so + // its authentication failure rejects before allocation. OAuth authentication + // failures and other catalog failures use only the configured model for this + // response, without caching, so session/prompt can run the existing PKCE flow. + let available_models: Vec = { + use crate::config::Provider; + match app.cfg.provider { + Provider::Databricks | Provider::DatabricksV2 => { + let models = match resolve_models_catalog( + &app.models_cache, + discover_databricks_models(&app.cfg), + ) + .await + { + Ok(models) => models, + Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { + return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()) + .await; + } + Err(error @ AgentError::LlmAuth(_)) => { + tracing::warn!( + error = %error, + "Databricks OAuth model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + Err(error) => { + tracing::warn!( + error = %error, + "Databricks model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + }; + models + .iter() + .map(|m| json!({ "modelId": m.id, "name": m.name })) + .collect() + } + _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], + } + }; + let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { Ok(m) => Arc::new(m), Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, @@ -445,36 +485,6 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen ); drop(sessions); - // Build a models catalog for the `session/new` response. For Databricks - // providers this advertises available models so the desktop ModelPicker and - // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we - // report only the configured model — live switching on those providers - // effectively requires respawn. - // - // `models_cache` caches only a successful discovery result (`get_or_try_init` - // leaves the cell empty on error so the next `session/new` call retries). On - // discovery failure the fallback is used for the immediate response without - // being written to the cell. - let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = resolve_models_catalog( - &app.models_cache, - app.cfg.provider, - &app.cfg.model, - discover_databricks_models(&app.cfg), - ) - .await; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() - } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } - }; - wire::send( wire_tx, wire::ok( @@ -870,8 +880,7 @@ fn session_token() -> Result { #[cfg(test)] mod tests { - use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; - use crate::config::Provider; + use crate::catalog::ModelEntry; use crate::types::AgentError; /// Regression: a discovery error must not pin the models_cache for the process lifetime. @@ -884,23 +893,14 @@ mod tests { #[tokio::test] async fn models_cache_does_not_pin_on_discovery_error() { let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); - let provider = Provider::DatabricksV2; - let model = "my-configured-model"; - // First call — discovery fails. Cell must remain empty; fallback returned. - let first = crate::resolve_models_catalog(&cache, provider, model, async { - Err::, AgentError>(AgentError::LlmAuth("transient failure".into())) + // First call — discovery failure is surfaced and leaves the cell empty. + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::Llm("transient failure".into())) }) - .await; - assert!( - cache.get().is_none(), - "cell must be empty after a discovery error — next session must retry" - ); - let expected_fallback = discovery_failure_fallback(provider, model); - assert_eq!( - first, expected_fallback, - "error path must return the provider-aware fallback" - ); + .await + .unwrap_err(); + assert!(matches!(error, AgentError::Llm(_))); // Second call — discovery succeeds. Cell is now populated and returned. let discovered = vec![ModelEntry { @@ -908,10 +908,11 @@ mod tests { name: "databricks-meta-llama-3-1-70b-instruct".into(), }]; let discovered_clone = discovered.clone(); - let second = crate::resolve_models_catalog(&cache, provider, model, async move { + let second = crate::resolve_models_catalog(&cache, async move { Ok::, AgentError>(discovered_clone) }) - .await; + .await + .unwrap(); assert_eq!( second, discovered, "second call must return the discovered catalog" @@ -927,78 +928,40 @@ mod tests { ); } - /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs - /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly — - /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if - /// the arm is un-split (i.e., if both providers return the v2 catalog on failure). - #[test] - fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() { - let configured = "my-serving-endpoint"; - let result = discovery_failure_fallback(Provider::Databricks, configured); + #[tokio::test] + async fn models_catalog_does_not_cache_oauth_auth_fallback() { + let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::LlmAuth("sign in again".into())) + }) + .await + .unwrap_err(); - // Legacy Databricks must advertise exactly the configured model — nothing more. - assert_eq!( - result.len(), - 1, - "legacy Databricks fallback must contain exactly one entry, got: {result:?}" - ); - assert_eq!( - result[0].id, configured, - "legacy Databricks fallback must be the configured model" - ); + assert!(matches!(error, AgentError::LlmAuth(_))); + assert!(cache.get().is_none()); - // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry. - let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec(); - for id in &result { - assert!( - !v2_ids.contains(&id.id.as_str()), - "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \ - may not be served by /serving-endpoints/{{model}}/invocations", - id.id - ); - } + let discovered = vec![ModelEntry { + id: "authenticated-model".into(), + name: "authenticated-model".into(), + }]; + let result = crate::resolve_models_catalog(&cache, async { + Ok::, AgentError>(discovered.clone()) + }) + .await + .unwrap(); + + assert_eq!(result, discovered); + assert_eq!(cache.get(), Some(&discovered)); } #[test] - fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() { - let configured = "my-configured-model"; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, - // plus the configured model so the picker can still represent the model - // the agent is actually running. + fn configured_model_fallback_is_trimmed_and_singular() { assert_eq!( - result.len(), - DATABRICKS_V2_KNOWN_MODELS.len() + 1, - "DatabricksV2 fallback must return all known models plus the configured model" - ); - let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - for known_id in DATABRICKS_V2_KNOWN_MODELS { - assert!( - result_ids.contains(known_id), - "DatabricksV2 fallback must include known model '{known_id}'" - ); - } - assert!( - result_ids.contains(&configured), - "DatabricksV2 fallback must include the configured model" - ); - } - - #[test] - fn databricks_discovery_failure_fallback_split_verified() { - // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies - // that the two providers' error-path behavior diverges (Wes W1 protection). - let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint"); - let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint"); - - let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect(); - let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect(); - - assert_ne!( - v1_ids, v2_ids, - "Provider::Databricks and Provider::DatabricksV2 must return different \ - fallback catalogs — if they are equal, the W1 arm split has been reverted" + crate::configured_model_fallback(" configured-model "), + vec![ModelEntry { + id: "configured-model".into(), + name: "configured-model".into(), + }] ); } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 73c7e1faf..220d99f9a 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1910,6 +1910,22 @@ where unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)"); } +pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { + PkceOAuthConfig { + discovery_url: format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ), + client_id: DATABRICKS_CLIENT_ID.into(), + scopes: DATABRICKS_OAUTH_SCOPES + .iter() + .map(|scope| (*scope).into()) + .collect(), + cache_namespace: "databricks".into(), + cache_dir_override: None, + } +} + /// Build the `TokenSource` for the configured provider. /// /// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's @@ -1929,21 +1945,9 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A if !cfg.api_key.is_empty() { return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); } - let discovery_url = format!( - "{}/oidc/.well-known/oauth-authorization-server", - cfg.base_url.trim_end_matches('/') - ); - let pkce = PkceOAuthConfig { - discovery_url, - client_id: DATABRICKS_CLIENT_ID.into(), - scopes: DATABRICKS_OAUTH_SCOPES - .iter() - .map(|s| (*s).into()) - .collect(), - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - Ok(PkceOAuthTokenSource::new(pkce)?) + Ok(PkceOAuthTokenSource::new(databricks_pkce_config( + &cfg.base_url, + ))?) } } } diff --git a/crates/buzz-agent/tests/databricks_oauth.rs b/crates/buzz-agent/tests/databricks_oauth.rs index 52acee107..fbe0dc1f8 100644 --- a/crates/buzz-agent/tests/databricks_oauth.rs +++ b/crates/buzz-agent/tests/databricks_oauth.rs @@ -20,6 +20,7 @@ use axum::{routing::get, routing::post, Json, Router}; use buzz_agent::auth::{PkceOAuthConfig, PkceOAuthTokenSource, TokenSource}; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use tempfile::TempDir; #[derive(Deserialize)] @@ -457,6 +458,7 @@ struct AgentHarness { stdin: tokio::process::ChildStdin, stdout: BufReader, next_id: i64, + _home: Option, } impl Drop for AgentHarness { @@ -467,20 +469,65 @@ impl Drop for AgentHarness { impl AgentHarness { async fn spawn_provider(provider: &str, base_url: &str, model: &str) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, 1, Some("test-bearer")).await + } + + async fn spawn_oauth_provider( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, max_sessions, None).await + } + + async fn spawn_provider_with_max_sessions( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options( + provider, + base_url, + model, + max_sessions, + Some("test-bearer"), + ) + .await + } + + async fn spawn_provider_with_options( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + token: Option<&str>, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); + let home = token + .is_none() + .then(|| TempDir::new().expect("create isolated OAuth home")); let mut cmd = tokio::process::Command::new(bin); cmd.env("BUZZ_AGENT_PROVIDER", provider) .env("DATABRICKS_HOST", base_url) .env("DATABRICKS_MODEL", model) - .env("DATABRICKS_TOKEN", "test-bearer") + .env_remove("DATABRICKS_TOKEN") .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_MAX_ROUNDS", "2") + .env("BUZZ_AGENT_MAX_SESSIONS", max_sessions.to_string()) .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); + if let Some(token) = token { + cmd.env("DATABRICKS_TOKEN", token); + } + if let Some(home) = &home { + cmd.env("HOME", home.path()); + } let mut child = cmd.spawn().expect("spawn buzz-agent"); let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()); @@ -489,9 +536,17 @@ impl AgentHarness { stdin, stdout, next_id: 1, + _home: home, } } + fn oauth_home(&self) -> &std::path::Path { + self._home + .as_ref() + .expect("harness was not started in OAuth mode") + .path() + } + async fn send(&mut self, method: &str, params: serde_json::Value) -> i64 { let id = self.next_id; self.next_id += 1; @@ -938,3 +993,288 @@ async fn session_set_model_empty_model_id_returns_error() { "error message must mention modelId, got: {msg}" ); } + +#[tokio::test] +async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() { + use axum::http::StatusCode; + use buzz_agent::config::{Config, Provider}; + use buzz_agent::discover_databricks_models; + + let requests = Arc::new(AtomicU64::new(0)); + let requests_for_route = requests.clone(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + (StatusCode::UNAUTHORIZED, "rejected bearer rejected") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host); + let error = discover_databricks_models(&cfg).await.unwrap_err(); + + assert!( + error.to_string().starts_with("llm auth:"), + "401 must retain auth semantics: {error}" + ); + assert!( + !error.to_string().contains("rejected bearer"), + "auth errors must not propagate provider bodies that may echo credentials: {error}" + ); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "a static token cannot refresh, so discovery must not issue a duplicate request" + ); +} + +fn databricks_oauth_cache_path(home: &std::path::Path, host: &str) -> std::path::PathBuf { + let discovery_url = format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ); + let mut hasher = Sha256::new(); + hasher.update(discovery_url.as_bytes()); + hasher.update(b"|"); + hasher.update(b"databricks-cli"); + hasher.update(b"|"); + hasher.update(b"all-apis,offline_access"); + let hash = hex::encode(hasher.finalize()); + home.join(".config") + .join("buzz-agent") + .join("oauth") + .join("databricks") + .join(format!("{hash}.json")) +} + +fn write_cached_oauth_token(home: &std::path::Path, host: &str, access_token: &str) { + let path = databricks_oauth_cache_path(home, host); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + serde_json::to_vec(&json!({ + "access_token": access_token, + "refresh_token": null, + "expires_at": SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + })) + .unwrap(), + ) + .unwrap(); +} + +#[tokio::test] +async fn oauth_missing_token_uses_configured_model_then_retries_discovery() { + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "endpoints": [{"name": "authenticated-model"}], + "next_page_token": null, + })) + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let mut h = + AgentHarness::spawn_oauth_provider("databricks_v2", &host, configured_model, 2).await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let first = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let first_response = h.recv_for(first).await; + assert!( + first_response["result"]["sessionId"].is_string(), + "missing OAuth token blocked session creation: {first_response}" + ); + assert_eq!( + first_response["result"]["models"]["availableModels"], + json!([{"modelId": "configured-model", "name": "configured-model"}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 0); + + write_cached_oauth_token(h.oauth_home(), &host, "cached-bearer"); + + let second = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let second_response = h.recv_for(second).await; + assert!( + second_response["result"]["sessionId"].is_string(), + "later authenticated session failed: {second_response}" + ); + assert_eq!( + second_response["result"]["models"]["availableModels"], + json!([{"modelId": "authenticated-model", "name": "authenticated-model"}]) + ); + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "OAuth fallback was cached instead of retrying discovery" + ); +} + +#[tokio::test] +async fn non_auth_discovery_failure_uses_configured_model_without_caching_fallback() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + (StatusCode::SERVICE_UNAVAILABLE, "catalog unavailable") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let normalized_configured_model = configured_model.trim(); + let mut h = + AgentHarness::spawn_provider_with_max_sessions("databricks_v2", &host, configured_model, 2) + .await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + for expected_attempts in 1..=2 { + let request = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let response = h.recv_for(request).await; + assert!( + response["result"]["sessionId"].is_string(), + "non-auth catalog failure blocked session creation: {response}" + ); + assert_eq!( + response["result"]["models"]["availableModels"], + json!([{"modelId": normalized_configured_model, "name": normalized_configured_model}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts); + } +} + +#[tokio::test] +async fn rejected_static_token_does_not_consume_capacity_or_spawn_mcp() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/ai-gateway/v2/endpoints", + get(move || { + let attempts = attempts_for_route.clone(); + async move { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err((StatusCode::UNAUTHORIZED, "rejected")) + } else { + Ok(Json(json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))) + } + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let mut h = AgentHarness::spawn_provider("databricks_v2", &host, "discovered-model").await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let pid_dir = TempDir::new().unwrap(); + let pid_file = pid_dir.path().join("mcp.pid"); + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let mcp_servers = json!([{ + "name": "must-not-spawn", + "command": fake_mcp, + "args": [], + "env": [{ + "name": "FAKE_MCP_PID_FILE", + "value": pid_file.to_string_lossy(), + }], + }]); + + let failed = h + .send( + "session/new", + json!({ "cwd": "/tmp", "mcpServers": mcp_servers }), + ) + .await; + let failed_response = h.recv_for(failed).await; + assert!(failed_response.get("error").is_some(), "{failed_response}"); + assert!( + failed_response["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("llm auth"), + "rejected static token did not retain auth semantics: {failed_response}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !pid_file.exists(), + "MCP process spawned before failed discovery was resolved" + ); + + let retry = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let retry_response = h.recv_for(retry).await; + assert!( + retry_response["result"]["sessionId"].is_string(), + "failed discovery consumed the sole session slot: {retry_response}" + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index 28fe6ce90..beffc2944 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -22,6 +22,7 @@ "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "policy_env": { "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_DISPLAY_NAME": "worker", "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", "BUZZ_ACP_RELAY_OBSERVER": "true", diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a..ee8868ad9 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1387,19 +1387,25 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec { .unwrap_or_default() } -/// Return a create-command response with an entity ID injected. -pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String { +/// Return a create-command response, injecting the entity ID **only** when the +/// relay accepted the event (`"accepted": true`). When the relay rejected the +/// event, emitting the locally-computed link would be misleading — callers +/// that copy or share the link would reference an event that was never stored. +pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String { let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({})); - v[id_key] = serde_json::json!(id_val); - if v.get("accepted").is_none() { - v["accepted"] = serde_json::json!(true); + let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false); + if accepted { + v[id_key] = serde_json::json!(id_val); } v.to_string() } /// Print a create-command response, injecting the generated entity ID. pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) { - println!("{}", create_response_with_id(resp, id_key, id_val)); + println!( + "{}", + create_response_with_id_if_accepted(resp, id_key, id_val) + ); } /// Extract a JSON field from relay write response messages shaped as @@ -2297,7 +2303,8 @@ mod retry_policy_tests { #[cfg(test)] mod tests { use super::{ - advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient, + advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, + BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2345,15 +2352,30 @@ mod tests { } #[test] - fn create_response_with_id_overrides_local_id_with_relay_id() { + fn create_response_with_id_if_accepted_injects_id_when_accepted() { let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#; - let out = create_response_with_id(raw, "workflow_id", "relay-id"); + let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id"); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID injected and original fields preserved when accepted. assert_eq!(v["workflow_id"].as_str(), Some("relay-id")); assert_eq!(v["event_id"].as_str(), Some("abc")); assert_eq!(v["accepted"].as_bool(), Some(true)); } + #[test] + fn create_response_with_id_if_accepted_omits_id_when_rejected() { + let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#; + let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID must not be present when relay rejected the event; emitting a + // link to an event that was never stored would mislead callers. + assert!( + v.get("workflow_id").is_none(), + "link field must be absent on rejected create" + ); + assert_eq!(v["accepted"].as_bool(), Some(false)); + } + // --- (a) auth-suppression regression pair --- fn make_auth_tag() -> (Tag, String) { diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3d7d92a1b..91c64a391 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -26,10 +27,16 @@ pub async fn cmd_create_issue( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce issues with it (see base_prompt.md). + let link = crate::links::issue_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -137,7 +144,8 @@ pub async fn cmd_issue_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 1ccc37a70..ad2c36e20 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -21,6 +21,55 @@ pub mod users; pub mod workflows; use crate::{client::normalize_write_response, error::CliError}; +use nostr::{EventBuilder, Tag}; + +const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; +const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME"; + +/// Add trusted, session-scoped provenance supplied by the ACP harness. +/// +/// Public channels use the standard NIP-29 `h` tag. Private conversations +/// intentionally omit their channel coordinate and retain only the agent's +/// display name. +pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result { + apply_git_provenance( + builder, + std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(), + std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(), + ) +} + +fn apply_git_provenance( + builder: EventBuilder, + channel_id: Option<&str>, + agent_name: Option<&str>, +) -> Result { + if let Some(channel_id) = channel_id { + let channel_id = channel_id.trim(); + uuid::Uuid::parse_str(channel_id) + .map_err(|_| CliError::Other("invalid git origin channel ID".into()))?; + let origin_tag = Tag::parse(["h", channel_id]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + if let Some(agent_name) = agent_name { + let agent_name = agent_name.trim(); + if agent_name.is_empty() + || agent_name.len() > 256 + || agent_name.chars().any(char::is_control) + { + return Err(CliError::Other( + "invalid private-conversation agent name".into(), + )); + } + let origin_tag = Tag::parse(["buzz-origin-agent", agent_name]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + Ok(builder) +} /// Parse a relay write-response JSON blob, mapping a duplicate (dominated) /// write to [`CliError::Conflict`] with the caller-supplied message. @@ -46,3 +95,47 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result, agent_name: Option<&str>) -> nostr::Event { + apply_git_provenance( + EventBuilder::new(Kind::Custom(1621), "issue"), + channel_id, + agent_name, + ) + .expect("apply provenance") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + #[test] + fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() { + let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + let event = event_with_origin(Some(channel_id), Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["h", channel_id])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent"))); + } + + #[test] + fn private_origin_exposes_only_agent_name() { + let event = event_with_origin(None, Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))); + } +} diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 13f1714d0..413934a3c 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -47,7 +48,8 @@ pub async fn cmd_send_patch( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -180,7 +182,8 @@ pub async fn cmd_patch_status( applied_as_commits: applied_as_commit.to_vec(), }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd..74c580a6d 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -55,10 +56,16 @@ pub async fn cmd_open_pr( revision_of: revision_of.map(str::to_string), }; - let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce PRs with it (see base_prompt.md). + let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -97,7 +104,9 @@ pub async fn cmd_update_pr( merge_base: merge_base.map(str::to_string), }; - let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -206,7 +215,8 @@ pub async fn cmd_pr_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 15e064d9c..e54b95ef2 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -261,8 +261,12 @@ pub async fn cmd_create_repo( channel, )?; let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce repos with it (see base_prompt.md). + let link = crate::links::repo_link(&owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b28..8a8bb053b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod links; mod validate; use clap::{Parser, Subcommand}; diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs new file mode 100644 index 000000000..043bdc48b --- /dev/null +++ b/crates/buzz-cli/src/links.rs @@ -0,0 +1,51 @@ +//! Canonical `buzz://` deep links for Buzz-hosted git entities. +//! +//! Buzz Desktop renders these links as rich preview cards in chat and +//! navigates in-app when they are clicked. The desktop parser lives in +//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must +//! stay format-compatible (see `golden_format_matches_desktop` below and +//! the mirror test in `entityLink.test.mjs`). +//! +//! Callers are expected to validate inputs first (`validate_hex64`, +//! `validate_repo_id`); the identifier charsets need no URL encoding. + +/// Build a `buzz://repo` link for a repository announcement (kind 30617). +pub fn repo_link(owner: &str, repo_id: &str) -> String { + format!("buzz://repo?owner={owner}&d={repo_id}") +} + +/// Build a `buzz://pr` link for a pull request event (kind 1618). +pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") +} + +/// Build a `buzz://issue` link for an issue event (kind 1621). +pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + + // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs + // ("builders emit the canonical cross-language link format"). + #[test] + fn golden_format_matches_desktop() { + assert_eq!( + pull_request_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + issue_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + repo_link(OWNER, "buzz-world"), + format!("buzz://repo?owner={OWNER}&d=buzz-world") + ); + } +} 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-media/src/validation.rs b/crates/buzz-media/src/validation.rs index f1387fc9d..450f8f353 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -858,7 +858,7 @@ fn validate_mp4_metadata_free(path: &Path) -> Result<(), MediaError> { *b"ftyp", *b"moov", *b"mdat", *b"free", *b"skip", *b"wide", *b"trak", *b"mdia", *b"minf", *b"stbl", *b"edts", *b"dinf", *b"sinf", *b"schi", *b"udta", *b"mvhd", *b"tkhd", *b"mdhd", *b"hdlr", *b"vmhd", *b"smhd", *b"dref", *b"url ", *b"urn ", *b"stsd", *b"stts", *b"stss", - *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"elst", + *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"sdtp", *b"elst", ]; fn walk( file: &mut std::fs::File, @@ -2337,6 +2337,31 @@ mod tests { assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); } + #[test] + fn test_accepts_standard_sample_dependency_table() { + let bytes = [ + box_wrap(b"ftyp", b"isom\0\0\0\0isom"), + box_wrap( + b"moov", + &box_wrap( + b"trak", + &box_wrap( + b"mdia", + &box_wrap( + b"minf", + &box_wrap(b"stbl", &box_wrap(b"sdtp", &[0x20, 0x10])), + ), + ), + ), + ), + box_wrap(b"mdat", b""), + ] + .concat(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); + } + #[test] fn test_rejects_excessive_mp4_box_nesting() { let mut nested = box_wrap(b"free", b""); 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/package.json b/desktop/package.json index 036533308..a1fd2e919 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.4", + "version": "0.5.5", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e561e502c..f3c2936fe 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", @@ -138,6 +139,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/Cargo.lock b/desktop/src-tauri/Cargo.lock index da80c5b07..afd119c84 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 655401856..f0ab16e18 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.4" +version = "0.5.5" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb374771..2b997af89 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,8 +13,6 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); @@ -75,21 +73,6 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } - // Presence-only flag: when set (any non-empty value), observer-feed archive - // defaults to ON for the current identity on first run. OSS builds leave - // this unset → default OFF. No JSON validation needed — the command only - // checks `.is_some()`. - if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); - } - - // Presence-only flag: when set (any non-empty value), agent-turn-metric - // archive defaults to ON for the current identity on first run. OSS builds - // leave this unset → default OFF. - if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); - } - // Presence-only release capability: internal desktop builds opt into // auto-connecting their configured default relay on first run. OSS builds // leave this unset and retain explicit community selection. 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/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs index 43cfc7b08..77de87087 100644 --- a/desktop/src-tauri/src/commands/agent_metric_archive.rs +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -1,35 +1,18 @@ -//! Build-time flag for agent-turn-metric archive default. +//! Agent-turn-metric archive default — always enabled. //! -//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time -//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` -//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 -//! (agent turn metrics) on first run for the current identity. -//! -//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in -//! manually via the Local Archive settings card. +//! `agent_metric_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this once at startup to decide whether to seed the +//! `owner_p` [44200] save subscription for the current identity on first run. +//! The `hasExplicitChoice` guard in the TS seed hook ensures a user who has +//! explicitly opted out remains opted out. -/// Returns `true` when an internal build has agent-turn-metric archive -/// default-on. +/// Returns `true`: agent-turn-metric archive defaults to enabled for all builds. /// -/// The frontend calls this once at startup to decide whether to seed the -/// `owner_p` [44200] save subscription. The result is stable for the lifetime -/// of the binary — it is baked at compile time. +/// The frontend uses this to decide whether to auto-seed an `owner_p` [44200] +/// save subscription on first run. Existing explicit choices (stored in +/// localStorage per identity) are preserved by the seed hook's `hasExplicitChoice` +/// guard — this default only applies to identities that have never made a choice. #[tauri::command] pub fn agent_metric_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { - // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT - // baked in), this must return false. - assert!( - !agent_metric_archive_default_enabled(), - "expected false in OSS/test build" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 8aef1d038..9ff48c42d 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -137,6 +137,7 @@ pub async fn get_agent_models( &effective_provider, &merged_env, persisted_model.clone(), + DatabricksAuthIntent::InteractiveModelPicker, ) .await? { @@ -307,9 +308,14 @@ pub async fn discover_agent_models( return Ok(models); } - if let Some(models) = - discover_databricks_models(&state.http_client, &effective_provider, &merged_env, None) - .await? + if let Some(models) = discover_databricks_models( + &state.http_client, + &effective_provider, + &merged_env, + None, + DatabricksAuthIntent::PassiveDraftDiscovery, + ) + .await? { return Ok(models); } @@ -681,97 +687,14 @@ async fn discover_anthropic_models( })) } -// --------------------------------------------------------------------------- -// Databricks model discovery (v1 + v2) -// --------------------------------------------------------------------------- -// -// Delegates to buzz_agent_pkg::catalog::discover_databricks_models, which -// acquires auth in-process via build_token_source: -// - Static bearer (DATABRICKS_TOKEN): returned immediately. -// - PKCE cache hit: returned from disk without a browser flow. -// - No token, no cache: returns Err(LlmAuth) → we return Ok(None) and fall -// through to run_agent_models_command. Never hangs, never opens a browser. - -fn is_databricks_provider(provider: Option<&str>) -> bool { - matches!( - provider - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("databricks" | "databricks_v2" | "databricks-v2") - ) -} - -fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { - if provider.trim().eq_ignore_ascii_case("databricks_v2") - || provider.trim().eq_ignore_ascii_case("databricks-v2") - { - buzz_agent_pkg::config::Provider::DatabricksV2 - } else { - buzz_agent_pkg::config::Provider::Databricks - } -} - -async fn discover_databricks_models( - _client: &reqwest::Client, - provider: &DiscoveryProvider, - env: &BTreeMap, - selected_model: Option, -) -> Result, String> { - let provider_str = match provider.as_deref() { - Some(p) if is_databricks_provider(Some(p)) => p, - _ => return Ok(None), - }; - - let host = match env_or_process_value(env, "DATABRICKS_HOST") { - Some(h) => h, - None => return Ok(None), // no host → fall through to subprocess - }; - - // api_key = DATABRICKS_TOKEN (empty string = use PKCE cache). - let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - - let agent_provider = databricks_agent_provider(provider_str); - let cfg = buzz_agent_pkg::config::Config::for_discovery(agent_provider, api_key, host); - - // Build a redaction env so the token never appears in surfaced errors. - let token_for_redact = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &token_for_redact); - - let entries = match buzz_agent_pkg::discover_databricks_models(&cfg).await { - Ok(e) => e, - Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { - // No token + no PKCE cache → fall through to subprocess. - return Ok(None); - } - Err(e) => { - let msg = crate::managed_agents::redact_env_values_in(&e.to_string(), &redaction_env); - return Err(format!("Databricks model discovery failed: {msg}")); - } - }; - - if entries.is_empty() { - return Err("Databricks model discovery returned no models".to_string()); - } - - let models = entries - .into_iter() - .map(|e| AgentModelInfo { - id: e.id, - name: Some(e.name), - description: None, - }) - .collect(); - - Ok(Some(AgentModelsResponse { - agent_name: provider_str.trim().to_string(), - agent_version: "models-api".to_string(), - models, - agent_default_model: None, - selected_model, - supports_switching: true, - })) -} +#[path = "agent_models_databricks.rs"] +mod databricks; +#[cfg(test)] +use databricks::{ + databricks_sign_in_required_error, databricks_static_token_error, is_databricks_provider, + should_start_interactive_auth, +}; +use databricks::{discover_databricks_models, DatabricksAuthIntent}; /// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch /// to `record`, enforcing the linked-instance write guard: a definition-linked diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs new file mode 100644 index 000000000..63b4564e6 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -0,0 +1,174 @@ +//! Databricks v1/v2 model discovery and interactive reauthentication. + +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use crate::commands::agent_models_env::{ + env_or_process_value, redaction_env_with_value, DiscoveryProvider, +}; +use crate::managed_agents::AgentModelInfo; +use crate::managed_agents::AgentModelsResponse; + +// Model discovery can be triggered by multiple dialogs at once. Permit only one +// callback listener/browser flow for the process-wide OAuth cache. +static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); + +pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("databricks" | "databricks_v2" | "databricks-v2") + ) +} + +fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { + if provider.trim().eq_ignore_ascii_case("databricks_v2") + || provider.trim().eq_ignore_ascii_case("databricks-v2") + { + buzz_agent_pkg::config::Provider::DatabricksV2 + } else { + buzz_agent_pkg::config::Provider::Databricks + } +} + +pub(super) fn databricks_static_token_error( + error: &str, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(error, redaction_env); + format!("Databricks rejected DATABRICKS_TOKEN; update it in agent settings: {message}") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum DatabricksAuthIntent { + /// A saved agent's model picker was opened by the user. + InteractiveModelPicker, + /// Discovery was triggered automatically from unsaved form state. + PassiveDraftDiscovery, +} + +impl DatabricksAuthIntent { + fn allows_interactive_auth(self) -> bool { + matches!(self, Self::InteractiveModelPicker) + } +} + +pub(super) fn databricks_sign_in_required_error() -> String { + "Databricks sign-in is required; save this agent, then open its model picker to sign in, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth( + api_key: &str, + auth_intent: DatabricksAuthIntent, +) -> bool { + api_key.is_empty() && auth_intent.allows_interactive_auth() +} + +pub(super) async fn discover_databricks_models( + _client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, + auth_intent: DatabricksAuthIntent, +) -> Result, String> { + let provider_name = match provider.as_deref() { + Some(provider_name) if is_databricks_provider(Some(provider_name)) => provider_name, + _ => return Ok(None), + }; + + let host = match env_or_process_value(env, "DATABRICKS_HOST") { + Some(host) => host, + None => return Ok(None), + }; + let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); + let config = buzz_agent_pkg::config::Config::for_discovery( + databricks_agent_provider(provider_name), + api_key.clone(), + host.clone(), + ); + let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + + let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) + if should_start_interactive_auth(&api_key, auth_intent) => + { + let _auth = AUTH_GATE.lock().await; + match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + buzz_agent_pkg::authenticate_databricks(&host) + .await + .map_err(|error| { + format_redacted_error( + "Databricks sign-in failed", + &error, + &redaction_env, + ) + })?; + buzz_agent_pkg::discover_databricks_models(&config) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + } + } + Err(buzz_agent_pkg::AgentError::LlmAuth(error)) if !api_key.is_empty() => { + return Err(databricks_static_token_error(&error, &redaction_env)); + } + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + return Err(databricks_sign_in_required_error()); + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + }; + + if entries.is_empty() { + return Err("Databricks model discovery returned no models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: provider_name.trim().to_string(), + agent_version: "models-api".to_string(), + models: entries + .into_iter() + .map(|entry| AgentModelInfo { + id: entry.id, + name: Some(entry.name), + description: None, + }) + .collect(), + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} + +fn format_redacted_error( + context: &str, + error: &impl std::fmt::Display, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); + format!("{context}: {message}") +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 14c981d73..e7d0e70fd 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -576,6 +576,29 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(None)); } +#[test] +fn databricks_interactive_auth_requires_explicit_intent_and_no_static_token() { + assert!(should_start_interactive_auth( + "", + DatabricksAuthIntent::InteractiveModelPicker + )); + assert!(!should_start_interactive_auth( + "", + DatabricksAuthIntent::PassiveDraftDiscovery + )); + assert!(!should_start_interactive_auth( + "static-token", + DatabricksAuthIntent::InteractiveModelPicker + )); +} + +#[test] +fn databricks_passive_auth_error_has_reachable_create_flow_guidance() { + let error = databricks_sign_in_required_error(); + assert!(error.contains("save this agent, then open its model picker")); + assert!(error.contains("buzz-agent auth databricks")); +} + #[test] fn model_discovery_error_converts_dangling_sentinel_to_sentence() { // get_agent_models is a user-facing surface: a dangling harness must @@ -881,3 +904,21 @@ fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() { ); } } + +#[test] +fn databricks_static_token_error_redacts_echoed_token() { + let token = "secret-databricks-token"; + let redaction_env = BTreeMap::from([("DATABRICKS_TOKEN".to_string(), token.to_string())]); + + let error = databricks_static_token_error( + &format!("Databricks rejected bearer {token}"), + &redaction_env, + ); + + assert!(error.contains("[REDACTED]"), "got: {error}"); + assert!(!error.contains(token), "token leaked in error: {error}"); + assert!( + error.contains("update it in agent settings"), + "error lost its remediation: {error}" + ); +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230..b90bf49b3 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -40,7 +40,9 @@ pub(super) fn build_launch_block( effective_model: Option<&str>, owner_pubkey: &str, ) -> serde_json::Value { - use crate::managed_agents::{known_acp_runtime, resolve_session_title, SESSION_TITLE_ENV_VAR}; + use crate::managed_agents::{ + known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + }; let runtime = known_acp_runtime(&descriptor.command); let mut policy_env = BTreeMap::new(); @@ -73,10 +75,11 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); } if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { - policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value.clone()); + policy_env.insert(DISPLAY_NAME_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); } @@ -250,6 +253,7 @@ mod tests { "Coordinate" ); assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); diff --git a/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs b/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs index 654b2e582..661f65ab9 100644 --- a/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs +++ b/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs @@ -125,7 +125,13 @@ async fn test_full_tail_stop_spawn_receipt_register_save() { 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( + &record, + &[], + &[], + relay_url, + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce".to_string(), @@ -191,7 +197,7 @@ async fn test_full_tail_stop_spawn_receipt_register_save() { Ok(()) }, // spawn_fn: record captured relay+owner, return a fresh process. - move |_app, _rec, relay, owner, _personas, _global, _teams| { + move |_app, rec, relay, owner, _personas, global, teams| { *spawn_relay2.lock().unwrap() = Some(relay.to_string()); *spawn_owner2.lock().unwrap() = owner.map(str::to_string); // Return an immediately-exiting child as the fake process. @@ -204,7 +210,14 @@ async fn test_full_tail_stop_spawn_receipt_register_save() { Ok(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( + rec, + &[], + teams, + relay, + global, + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce-spawn".to_string(), diff --git a/desktop/src-tauri/src/commands/global_agent_config_tests.rs b/desktop/src-tauri/src/commands/global_agent_config_tests.rs index 4ca09743f..5d78a9da5 100644 --- a/desktop/src-tauri/src/commands/global_agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/global_agent_config_tests.rs @@ -550,7 +550,13 @@ fn test_record_mesh_change_after_preflight_aborts_before_stop() { 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( + &record, + &[], + &[], + relay_url, + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce-mesh".to_string(), diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b34023..86a91a984 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -3,17 +3,17 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; -use crate::relay::{ - classify_request_error, parse_json_response, relay_api_base_url_with_override, - relay_error_message, -}; +use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, - transcode_heic_path_to_jpeg_bytes, + transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, + transcode_heic_path_to_jpeg_bytes_with_cancellation, }; +use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -410,51 +410,6 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { ) } -async fn send_upload_attempt( - state: &AppState, - url: String, - auth_header: &str, - mime: &str, - sha256: &str, - body: bytes::Bytes, - progress: Option<&(tauri::AppHandle, String)>, -) -> Result { - let req = state - .http_client - .put(url) - .header("Authorization", auth_header) - .header("Content-Type", mime) - .header("X-SHA-256", sha256); - - let response = if let Some((app, progress_id)) = progress { - use tauri::Emitter; - let app = app.clone(); - let progress_id = progress_id.clone(); - let total = body.len() as u64; - let chunk_size = 64 * 1024; - let chunk_count = body.len().div_ceil(chunk_size); - let mut sent: u64 = 0; - let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { - let start = i * chunk_size; - let end = usize::min(start + chunk_size, body.len()); - let chunk = body.slice(start..end); - sent += chunk.len() as u64; - let _ = app.emit( - "media-upload-progress", - serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), - ); - Ok::(chunk) - })); - req.header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send() - .await - } else { - req.body(body).send().await - }; - response.map_err(|error| classify_request_error(&error)) -} - pub(crate) async fn upload_image_bytes( body: Vec, state: &AppState, @@ -464,7 +419,7 @@ pub(crate) async fn upload_image_bytes( return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None).await + do_upload(body, &mime, state, None, None).await } async fn do_upload( @@ -472,6 +427,7 @@ async fn do_upload( mime: &str, state: &AppState, progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -494,25 +450,34 @@ async fn do_upload( URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); let body = bytes::Bytes::from(body); + if let Some((app, progress_id)) = progress.as_ref() { + emit_media_upload_phase(app, Some(progress_id.as_str()), "uploading"); + } let mut resp = send_upload_attempt( state, - format!("{base_url}/upload"), - &auth_header, - mime, - &sha256, - body.clone(), - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body: body.clone(), + progress: progress.as_ref(), + cancellation, + }, ) .await?; if should_retry_legacy_upload(resp.status()) { resp = send_upload_attempt( state, - format!("{base_url}/media/upload"), - &auth_header, - mime, - &sha256, - body, - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/media/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body, + progress: progress.as_ref(), + cancellation, + }, ) .await?; } @@ -559,7 +524,7 @@ pub async fn upload_media( let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None).await + do_upload(body, &mime, &state, None, None).await } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -573,6 +538,7 @@ async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, + progress: Option<(tauri::AppHandle, String)>, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -639,10 +605,9 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, None).await?; - + let mut descriptor = do_upload(body, &mime, state, progress, None).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None).await { + match do_upload(poster, "image/jpeg", state, None, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -675,6 +640,7 @@ async fn process_picked_path( #[tauri::command] pub async fn pick_and_upload_media( app: tauri::AppHandle, + progress_id: Option, state: State<'_, AppState>, ) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -694,7 +660,8 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, false).await?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; descriptors.push(descriptor); } @@ -735,30 +702,37 @@ pub async fn pick_and_upload_image( }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true).await?; + let descriptor = process_picked_path(path, &state, true, None).await?; Ok(Some(descriptor)) } -/// Upload raw bytes directly (for paste and drag-drop). -/// -/// The renderer already has the bytes in memory from the clipboard/drag event. -/// If the bytes are a video, they're written to a temp file, transcoded via -/// ffmpeg, and the transcoded output is uploaded instead. -#[tauri::command] -pub async fn upload_media_bytes( +pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, progress_id: Option, app: tauri::AppHandle, state: State<'_, AppState>, + cancellation: Option<&CancellationToken>, ) -> Result { if data.is_empty() { return Err("empty upload".to_string()); } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + + emit_media_upload_phase(&app, progress_id.as_deref(), "preparing"); + + let heic_by_extension = filename + .as_deref() + .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let (body, poster_bytes) = if is_video_file(&data) { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video"); // Video: write to temp → transcode + extract poster → read results. // All blocking I/O runs off the async runtime via spawn_blocking. + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -766,17 +740,19 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_and_extract_poster(&tmp_input) + transcode_and_extract_poster_with_cancellation(&tmp_input, cancellation.as_ref()) })(); let _ = std::fs::remove_file(&tmp_input); result }) .await .map_err(|e| format!("transcode task failed: {e}"))?? - } else if is_heic_file(&data) { + } else if is_heic_file(&data) || heic_by_extension { + emit_media_upload_phase(&app, progress_id.as_deref(), "converting-image"); // HEIC/HEIF still pasted/dropped: no filename here, so detection is // magic-bytes only. ffmpeg needs a path, so write to temp, transcode // to JPEG, and clean up. (Mirrors mobile's pre-upload transcode.) + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -784,7 +760,11 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_heic_path_to_jpeg_bytes(&tmp_input).map(|jpeg| (jpeg, None)) + transcode_heic_path_to_jpeg_bytes_with_cancellation( + &tmp_input, + cancellation.as_ref(), + ) + .map(|jpeg| (jpeg, None)) })(); let _ = std::fs::remove_file(&tmp_input); result @@ -799,11 +779,15 @@ pub async fn upload_media_bytes( let body = sanitize_image_for_upload(body, &mime)?; // Upload video first, then poster (best-effort). - let progress = progress_id.map(|id| (app, id)); - let mut descriptor = do_upload(body, &mime, &state, progress).await?; + let progress = progress_id.as_ref().map(|id| (app.clone(), id.clone())); + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?; + emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None).await { + match do_upload(poster, "image/jpeg", &state, None, cancellation).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs new file mode 100644 index 000000000..a74ccd4df --- /dev/null +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -0,0 +1,96 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use tauri::{ + ipc::{InvokeBody, Request}, + State, +}; + +use crate::app_state::AppState; + +use super::{ + media::{upload_media_bytes_inner, BlobDescriptor}, + media_upload_progress::{ + begin_media_upload, cancel_media_upload as cancel_registered_media_upload, + finish_media_upload, + }, +}; + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// If the bytes are a video, they're written to a temp file, transcoded via +/// ffmpeg, and the transcoded output is uploaded instead. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + filename: Option, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + upload_media_bytes_inner(data, filename, progress_id, app, state, None).await +} + +fn decode_raw_upload_header(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|error| format!("invalid raw upload header: {error}"))?; + String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}")) +} + +fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result, String> { + request + .headers() + .get(name) + .map(|value| { + value + .to_str() + .map_err(|error| format!("invalid {name} header: {error}")) + .and_then(decode_raw_upload_header) + }) + .transpose() +} + +/// Cancel the native upload associated with a background progress ID. +#[tauri::command] +pub fn cancel_media_upload(progress_id: String) { + cancel_registered_media_upload(&progress_id); +} + +/// Upload raw IPC bytes without expanding a large browser File into JSON. +#[tauri::command] +pub async fn upload_media_bytes_raw( + request: Request<'_>, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + }; + let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; + let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; + + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_raw_upload_header_preserves_unicode() { + let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); + assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 46a5decaa..3fb7eda5f 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -6,6 +6,7 @@ //! `validate_video_file()`) and to produce a JPEG poster frame. use crate::managed_agents::resolve_command; +use tokio_util::sync::CancellationToken; /// Build an ffmpeg command without inheriting user-controlled process knobs. /// @@ -121,7 +122,7 @@ pub(super) fn has_heic_extension(path: &std::path::Path) -> bool { /// blocking a Tokio worker thread indefinitely. const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); -/// Run an ffmpeg command with a wall-clock timeout. +/// Run an ffmpeg command with a wall-clock timeout and optional cancellation. /// /// Spawns the child process, polls `try_wait()` every 500ms, and kills it /// if the deadline is exceeded. Returns the same `Output` as `Command::output()`. @@ -131,10 +132,14 @@ const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); /// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB), /// the child blocks on write() and never exits — causing a false timeout. /// `-loglevel error` suppresses progress spam, keeping stderr small. -pub(super) fn run_ffmpeg_with_timeout( +fn run_ffmpeg_with_cancellation( cmd: &mut std::process::Command, timeout: std::time::Duration, + cancellation: Option<&CancellationToken>, ) -> Result { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; @@ -162,6 +167,11 @@ pub(super) fn run_ffmpeg_with_timeout( } Ok(None) => { // Still running — check deadline. + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = child.kill(); + let _ = child.wait(); + return Err("upload cancelled".to_string()); + } if std::time::Instant::now() > deadline { let _ = child.kill(); let _ = child.wait(); // reap zombie @@ -181,14 +191,15 @@ pub(super) fn run_ffmpeg_with_timeout( /// relay's `validate_video_file()`. /// /// Returns the path to a temp file. Caller must clean up. -pub(super) fn transcode_to_mp4( +fn transcode_to_mp4_with_cancellation( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4())); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -240,7 +251,11 @@ pub(super) fn transcode_to_mp4( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), FFMPEG_TIMEOUT, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -265,9 +280,10 @@ pub(super) fn transcode_to_mp4( /// Uses `-frames:v 1` so multi-image HEIF containers (Live Photos, bursts) /// yield a single still, and `-q:v 2` for high JPEG quality. Returns the path /// to a temp file. Caller must clean up. -pub(super) fn transcode_heic_to_jpeg( +fn transcode_heic_to_jpeg( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-heic-{}.jpg", uuid::Uuid::new_v4())); @@ -275,7 +291,7 @@ pub(super) fn transcode_heic_to_jpeg( // Single-frame image decode — 60s is generous even for large HEICs. let heic_timeout = std::time::Duration::from_secs(60); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -301,7 +317,11 @@ pub(super) fn transcode_heic_to_jpeg( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), heic_timeout, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -323,9 +343,16 @@ pub(super) fn transcode_heic_to_jpeg( /// file. Mirrors `transcode_and_extract_poster` but for images (no poster). pub(super) fn transcode_heic_path_to_jpeg_bytes( source: &std::path::Path, +) -> Result, String> { + transcode_heic_path_to_jpeg_bytes_with_cancellation(source, None) +} + +pub(super) fn transcode_heic_path_to_jpeg_bytes_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result, String> { let ffmpeg_path = find_ffmpeg()?; - let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path)?; + let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path, cancellation)?; let bytes = std::fs::read(&jpeg_path).map_err(|e| format!("failed to read transcoded HEIC: {e}")); let _ = std::fs::remove_file(&jpeg_path); @@ -340,9 +367,10 @@ pub(super) fn transcode_heic_path_to_jpeg_bytes( /// /// Best-effort: returns `Err` on failure — callers should log and continue /// without a poster rather than failing the entire video upload. -pub(super) fn extract_poster_frame( +fn extract_poster_frame_with_cancellation( mp4_path: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { let output = std::env::temp_dir().join(format!("buzz-poster-{}.jpg", uuid::Uuid::new_v4())); @@ -350,7 +378,7 @@ pub(super) fn extract_poster_frame( let poster_timeout = std::time::Duration::from_secs(30); // Try seeking to 1s first (avoids black first frames from fade-ins). - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -369,6 +397,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; // If seek to 1s failed (video shorter than 1s), retry from first frame. @@ -381,7 +410,7 @@ pub(super) fn extract_poster_frame( eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}"); } let _ = std::fs::remove_file(&output); - let fallback = run_ffmpeg_with_timeout( + let fallback = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -398,6 +427,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; if !fallback.status.success() || !output.exists() { @@ -417,22 +447,35 @@ pub(super) fn extract_poster_frame( /// and the video bytes are still valid. All temp files are cleaned up. pub(super) fn transcode_and_extract_poster( source: &std::path::Path, +) -> Result<(Vec, Option>), String> { + transcode_and_extract_poster_with_cancellation(source, None) +} + +pub(super) fn transcode_and_extract_poster_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result<(Vec, Option>), String> { let ffmpeg_path = find_ffmpeg()?; - let transcoded = transcode_to_mp4(source, &ffmpeg_path)?; + let transcoded = transcode_to_mp4_with_cancellation(source, &ffmpeg_path, cancellation)?; // Extract poster from the transcoded file (not the original — guarantees decodability). - let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) { - Ok(poster_path) => { - let bytes = std::fs::read(&poster_path).ok(); - let _ = std::fs::remove_file(&poster_path); - bytes - } - Err(e) => { - eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); - None - } - }; + let poster_bytes = + match extract_poster_frame_with_cancellation(&transcoded, &ffmpeg_path, cancellation) { + Ok(poster_path) => { + let bytes = std::fs::read(&poster_path).ok(); + let _ = std::fs::remove_file(&poster_path); + bytes + } + Err(e) => { + eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); + None + } + }; + + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = std::fs::remove_file(&transcoded); + return Err("upload cancelled".to_string()); + } let video_bytes = std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}")); @@ -599,7 +642,8 @@ mod tests { return; } - let output = transcode_to_mp4(&source, &ffmpeg).expect("transcode fixture"); + let output = + transcode_to_mp4_with_cancellation(&source, &ffmpeg, None).expect("transcode fixture"); let bytes = std::fs::read(&output).expect("read transcoded video"); let _ = std::fs::remove_file(&source); let _ = std::fs::remove_file(&output); diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs new file mode 100644 index 000000000..850afe1b1 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -0,0 +1,126 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tauri::Emitter; +use tokio_util::sync::CancellationToken; + +use crate::{app_state::AppState, relay::classify_request_error}; + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { + let progress_id = progress_id?; + let cancel = CancellationToken::new(); + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.insert(progress_id.to_string(), cancel.clone()); + } + Some(cancel) +} + +pub(super) fn cancel_media_upload(progress_id: &str) { + if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + if let Some(cancel) = uploads.get(progress_id) { + cancel.cancel(); + } + } +} + +pub(super) fn finish_media_upload(progress_id: Option<&str>) { + let Some(progress_id) = progress_id else { + return; + }; + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.remove(progress_id); + } +} + +pub(super) struct UploadAttempt<'a> { + pub url: String, + pub auth_header: &'a str, + pub mime: &'a str, + pub sha256: &'a str, + pub body: bytes::Bytes, + pub progress: Option<&'a (tauri::AppHandle, String)>, + pub cancellation: Option<&'a CancellationToken>, +} + +pub(super) async fn send_upload_attempt( + state: &AppState, + attempt: UploadAttempt<'_>, +) -> Result { + let UploadAttempt { + url, + auth_header, + mime, + sha256, + body, + progress, + cancellation, + } = attempt; + let req = state + .http_client + .put(url) + .header("Authorization", auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", sha256); + + let response = if let Some((app, progress_id)) = progress { + let app = app.clone(); + let progress_id = progress_id.clone(); + let total = body.len() as u64; + let chunk_size = 64 * 1024; + let chunk_count = body.len().div_ceil(chunk_size); + let mut sent: u64 = 0; + let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { + let start = i * chunk_size; + let end = usize::min(start + chunk_size, body.len()); + let chunk = body.slice(start..end); + sent += chunk.len() as u64; + let _ = app.emit( + "media-upload-progress", + serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), + ); + Ok::(chunk) + })); + let request = req + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)) + .send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + } else { + let request = req.body(body).send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + }; + response.map_err(|error| classify_request_error(&error)) +} + +pub(super) fn emit_media_upload_phase( + app: &tauri::AppHandle, + progress_id: Option<&str>, + phase: &'static str, +) { + let Some(id) = progress_id else { + return; + }; + let _ = app.emit( + "media-upload-phase", + serde_json::json!({ "id": id, "phase": phase }), + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17..237bc06e8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,8 +28,10 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_raw; mod media_snapshot_png; mod media_transcode; +mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; mod messages; @@ -85,6 +87,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs index 707e86b63..d8b2832b9 100644 --- a/desktop/src-tauri/src/commands/observer_archive.rs +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -1,54 +1,18 @@ -//! Build-time flag and runtime dev-nest check for observer-feed archive policy. +//! Observer-feed archive default — always enabled. //! -//! `observer_archive_default_enabled()` returns `true` when either: -//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal -//! builds bake in the flag via `build.rs`), **or** -//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the -//! case for all dev builds launched with `just staging` or `just dev`. -//! -//! When `true`, the frontend reconciles the observer archive subscription -//! every startup — unconditionally ensuring kind 24200 exists in the DB -//! regardless of stale localStorage markers. -//! -//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` — -//! no reconciliation; the user manages the subscription via Settings. +//! `observer_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this every startup to decide whether to reconcile the +//! `owner_p` subscription for kind 24200 (observer frames). Kind 24200 events +//! are ephemeral — not stored by the relay — so local archiving is the only +//! way to retain them. -/// Returns `true` when observer-feed archive policy is enforced. +/// Returns `true`: observer-feed archive defaults to enabled for all builds. /// -/// True when the build has the internal baked flag set, or when the running -/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this -/// every startup to decide whether to reconcile the `owner_p` subscription. +/// The frontend reconciles the `owner_p` subscription every startup when this +/// returns `true`. A user who has explicitly disabled the toggle keeps it off +/// because the Settings card's explicit-opt-out path deletes the subscription +/// and the seed hook skips identities that already have an explicit choice. #[tauri::command] pub fn observer_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() - || crate::managed_agents::nest_is_dev() -} - -#[cfg(test)] -mod tests { - use super::*; - - // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock - // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so - // the compiled flag is the sole variable. No runner normalization needed. - // - // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be - // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with - // `--ignored` under both compile states; general `cargo test` skips it. - #[test] - #[ignore] - fn test_observer_archive_default_enabled_matches_expected() { - let result = observer_archive_default_enabled(); - let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect( - "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \ - the dual-compile CI step supplies it; bare `cargo test` is \ - not sufficient to validate compiled-flag behavior", - ); - let expected = expected_str == "true" || expected_str == "1"; - assert_eq!( - result, expected, - "observer_archive_default_enabled() returned {result}, \ - expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b4..c616d39db 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result Result { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(GitAuthConfig { + git_path: resolve_command("git") + .ok_or_else(|| "git was not found on PATH".to_string())?, + credential_helper: None, + nsec: String::new(), + allow_file_transport: false, + }); + } + build_git_auth_config(state) +} + pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result { let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; let credential_helper = resolve_command("git-credential-nostr"); @@ -288,6 +304,56 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> { Ok(()) } +fn validate_github_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("GitHub clone URL must use public https://github.com/owner/repository".into()); + } + let segments = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let valid_segment = |segment: &&str| { + !segment.starts_with('-') + && !segment.contains("..") + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + }; + if segments.len() != 2 || !segments.iter().all(valid_segment) { + return Err("GitHub clone URL must name one owner and repository".into()); + } + Ok(()) +} + +pub(crate) fn validate_local_clone_url(clone_url: &str) -> Result<(), String> { + if validate_clone_url(clone_url).is_ok() || validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + Err("clone URL must point at a Buzz repository or public GitHub repository".into()) +} + +pub(crate) fn validate_local_clone_url_for_workspace( + clone_url: &str, + state: &AppState, +) -> Result<(), String> { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + validate_workspace_clone_url(clone_url, state) +} + pub(crate) fn clone_url_owner(clone_url: &str) -> Option { let parsed = Url::parse(clone_url).ok()?; let segments = parsed @@ -329,6 +395,7 @@ mod tests { use super::{ clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, git_subcommand, validate_clone_url, validate_clone_url_against_relay, + validate_local_clone_url, }; #[test] @@ -441,4 +508,15 @@ mod tests { ) .is_err()); } + + #[test] + fn local_clone_url_allows_only_public_github_https_urls() { + assert!(validate_local_clone_url("https://github.com/block/buzz").is_ok()); + assert!(validate_local_clone_url("https://github.com/block/buzz.git").is_ok()); + assert!(validate_local_clone_url("http://github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com/block/buzz/issues").is_err()); + assert!(validate_local_clone_url("https://user@github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com.evil.test/block/buzz").is_err()); + assert!(validate_local_clone_url("https://gitlab.com/block/buzz").is_err()); + } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 624bbf4df..9e0685276 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -3,8 +3,9 @@ use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; use super::project_git_exec::{ - build_git_auth_config, build_git_auth_config_for_keys, clone_url_owner, run_git, - validate_clone_url, validate_workspace_clone_url, GitAuthConfig, + build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git, + validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url, + GitAuthConfig, }; use super::project_repo_paths::{ canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, @@ -353,7 +354,7 @@ pub(crate) fn clone_project_repository_blocking( default_branch: Option<&str>, auth: &GitAuthConfig, ) -> Result { - validate_clone_url(clone_url)?; + validate_local_clone_url(clone_url)?; let branch = normalize_branch_option(default_branch); if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? { return Ok(ProjectRepoCloneResult { @@ -411,8 +412,8 @@ pub async fn clone_project_repository( default_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; + validate_local_clone_url_for_workspace(&clone_url, &state)?; + let auth = build_git_clone_auth_config(&clone_url, &state)?; tauri::async_runtime::spawn_blocking(move || { clone_project_repository_blocking( repos_dir.as_deref(), diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index 31dbc74c6..c583dd0db 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -9,7 +9,10 @@ use crate::app_state::AppState; use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; -use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url}; +use super::project_git_exec::{ + build_git_auth_config, build_git_clone_auth_config, run_git, + validate_local_clone_url_for_workspace, validate_workspace_clone_url, +}; use super::project_git_workflow::clone_project_repository_blocking; use super::project_repo_paths::find_local_repo_dir; @@ -99,9 +102,8 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> { } /// Opens the OS terminal at the project's local checkout. When there is no -/// local checkout yet, clones the repository from `clone_url` (authenticated -/// with the identity key, same as push/snapshot) into the repos dir first, -/// then opens the terminal at the fresh checkout. +/// local checkout yet, clones the repository from `clone_url` into the repos +/// dir first, then opens the terminal at the fresh checkout. #[tauri::command] pub async fn open_project_terminal( repos_dir: Option, @@ -111,11 +113,16 @@ pub async fn open_project_terminal( state: State<'_, AppState>, ) -> Result { if let Some(clone_url) = clone_url.as_deref() { - validate_workspace_clone_url(clone_url, &state)?; + validate_local_clone_url_for_workspace(clone_url, &state)?; } - // Auth is only needed for the clone path — keep the result outside the - // blocking task so it owns no borrowed Tauri state. - let auth = build_git_auth_config(&state); + // Public GitHub clones stay anonymous; Buzz remotes use the workspace + // identity. Keep the result outside the blocking task so it borrows no + // Tauri state. + let auth = if let Some(clone_url) = clone_url.as_deref() { + build_git_clone_auth_config(clone_url, &state) + } else { + build_git_auth_config(&state) + }; tauri::async_runtime::spawn_blocking(move || { // An inaccessible repos root (fresh machine, nothing cloned yet) is // not fatal here — the clone path below creates the default root. A diff --git a/desktop/src-tauri/src/huddle/commands.rs b/desktop/src-tauri/src/huddle/commands.rs new file mode 100644 index 000000000..993d8e54e --- /dev/null +++ b/desktop/src-tauri/src/huddle/commands.rs @@ -0,0 +1,132 @@ +//! Small Huddle controls that mutate an active session. + +use std::sync::{atomic::Ordering, Arc}; + +use tauri::State; +use uuid::Uuid; + +use crate::{app_state::AppState, events, relay::submit_event}; + +use super::{relay_api::validate_pubkey_hex, HuddlePhase}; + +/// Update the clickable microphone control independently from the PTT shortcut. +#[tauri::command] +pub fn set_huddle_manual_mic_unmuted( + enabled: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.manual_mic_unmuted.store(enabled, Ordering::Release); + Ok(()) +} + +/// Immediately interrupt the agent utterance that is currently speaking. +#[tauri::command] +pub fn interrupt_huddle_speech( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + let tts_pipeline = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.tts_pipeline.as_ref().map(Arc::clone) + }; + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_active_speaker(&agent_pubkey); + } + Ok(()) +} + +/// Remove an agent from the active huddle without removing its parent-channel +/// membership. Keeping the parent membership intact means it remains available +/// to rejoin this huddle from the agent picker. +#[tauri::command] +pub async fn remove_agent_from_huddle( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + + let (ephemeral_channel_id, huddle_generation) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + + let is_huddle_agent = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if !is_huddle_agent { + return Err("agent is not in this huddle".to_string()); + } + + ( + huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?, + huddle.huddle_generation, + ) + }; + + let ephemeral_channel_uuid = + Uuid::parse_str(&ephemeral_channel_id).map_err(|error| error.to_string())?; + submit_event( + events::build_remove_member(ephemeral_channel_uuid, &agent_pubkey)?, + &state, + ) + .await?; + + let (roster_changed, tts_pipeline) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + (false, None) + } else { + let mut agent_pubkeys = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + let initial_count = agent_pubkeys.len(); + agent_pubkeys.retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + let changed = agent_pubkeys.len() != initial_count; + drop(agent_pubkeys); + + if changed { + huddle + .participants + .retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if let Some(settings_pubkey) = huddle + .agent_voice_settings + .keys() + .find(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)) + .cloned() + { + huddle.agent_voice_settings.remove(&settings_pubkey); + } + } + let tts_pipeline = changed + .then_some(huddle.tts_pipeline.as_ref()) + .flatten() + .map(Arc::clone); + (changed, tts_pipeline) + } + }; + + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_speaker(&agent_pubkey); + } + if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 99337400c..fcf29d688 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; +mod commands; pub mod jitter; pub mod models; pub mod pipeline; @@ -67,6 +68,9 @@ pub(super) fn drain_until_shutdown( // ── Re-exports ──────────────────────────────────────────────────────────────── +pub use commands::{ + interrupt_huddle_speech, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, +}; pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; @@ -868,11 +872,27 @@ pub async fn speak_agent_message( let sender = { let hs = state.huddle()?; + let agent_is_present = hs + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&speaker_pubkey)); + if !agent_is_present { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={route_id}" + ); + return Ok(()); + } hs.tts_pipeline .as_ref() .map(|pipeline| pipeline.text_sender()) + .map(|sender| { + let speaker_generation = sender.speaker_generation(&speaker_pubkey); + (sender, speaker_generation) + }) }; - let Some(sender) = sender else { + let Some((sender, speaker_generation)) = sender else { eprintln!( "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}" ); @@ -880,7 +900,13 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, speaker_pubkey, voice_reference, text) + .send( + route_id, + speaker_pubkey, + speaker_generation, + voice_reference, + text, + ) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 9572ac25b..e523ee22b 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -315,6 +315,7 @@ pub(crate) async fn maybe_start_stt_pipeline( expected_generation, stt_starting, ptt_active_for_stt, + manual_mic_unmuted_for_stt, old_stt, ) = { let mut hs = state.huddle()?; @@ -338,6 +339,11 @@ pub(crate) async fn maybe_start_stt_pipeline( } else { None }; + let manual_mic_unmuted = if hs.voice_input_mode == VoiceInputMode::PushToTalk { + Some(Arc::clone(&hs.manual_mic_unmuted)) + } else { + None + }; ( Arc::clone(&hs.tts_active), Arc::clone(&hs.agent_pubkeys), @@ -345,6 +351,7 @@ pub(crate) async fn maybe_start_stt_pipeline( hs.session_generation.load(Ordering::Acquire), stt_starting, ptt, + manual_mic_unmuted, old, ) }; @@ -352,7 +359,12 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) + stt::SttPipeline::new( + model_dir, + tts_active, + ptt_active_for_stt, + manual_mic_unmuted_for_stt, + ) }) .await; let (pipeline, text_rx) = match constructed { diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 0fe3a46f5..7acf5fe63 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -15,7 +15,7 @@ use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). /// -/// PTT: mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets +/// PTT (the default): mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets /// `ptt_active` and immediately cancels any playing TTS. Releasing the key /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// @@ -26,8 +26,8 @@ use super::{stt, tts}; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { - PushToTalk, #[default] + PushToTalk, VoiceActivity, } @@ -135,6 +135,10 @@ pub struct HuddleState { /// Shared with the STT pipeline for mic gating. #[serde(skip)] pub ptt_active: Arc, + /// True while the clickable microphone control is manually unmuted. + /// In PTT mode, either this flag or `ptt_active` opens the STT gate. + #[serde(skip)] + pub manual_mic_unmuted: Arc, } fn serialize_agent_pubkeys(v: &Arc>>, s: S) -> Result @@ -190,6 +194,7 @@ impl Clone for HuddleState { session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), + manual_mic_unmuted: Arc::clone(&self.manual_mic_unmuted), } } } @@ -221,6 +226,7 @@ impl Default for HuddleState { session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), + manual_mic_unmuted: Arc::new(AtomicBool::new(true)), } } } @@ -332,6 +338,13 @@ mod tests { assert!(!state.maybe_auto_enable_transcription_for_agents()); } + #[test] + fn defaults_to_push_to_talk_with_an_open_microphone() { + let state = HuddleState::default(); + assert_eq!(state.voice_input_mode, super::VoiceInputMode::PushToTalk); + assert!(state.manual_mic_unmuted.load(Ordering::Acquire)); + } + #[test] fn explicit_user_disable_is_not_undone_by_agent_presence() { let mut state = HuddleState::default(); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 30a47f449..70a808864 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -71,9 +71,10 @@ impl SttPipeline { /// therefore never cancel TTS. Push-to-talk and remote participant speech /// remain explicit, reliable barge-in paths. /// - /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT - /// pipeline only accumulates speech while the flag is true (key held). - /// When `None`, the pipeline runs in continuous VAD mode. + /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut + /// is enabled. The pipeline accepts speech while either input path is open; + /// manual unmute uses normal VAD flushing while a shortcut hold is grouped + /// into one utterance. /// /// Returns `Err` only if the thread cannot be spawned (OS error). /// If model files are missing, the worker logs and exits cleanly — @@ -87,6 +88,7 @@ impl SttPipeline { model_dir: PathBuf, tts_active: Arc, ptt_active: Option>, + manual_mic_unmuted: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -94,6 +96,7 @@ impl SttPipeline { let shutdown_worker = Arc::clone(&shutdown); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); + let manual_mic_unmuted_worker = manual_mic_unmuted.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) .spawn(move || { @@ -104,6 +107,7 @@ impl SttPipeline { shutdown_worker, tts_active, ptt_active_worker, + manual_mic_unmuted_worker, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -203,6 +207,7 @@ fn stt_worker( shutdown: Arc, tts_active: Arc, ptt_active: Option>, + manual_mic_unmuted: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── use rubato::{Fft, FixedSync, Resampler}; @@ -275,9 +280,12 @@ fn stt_worker( // ── 5. Main loop ────────────────────────────────────────────────────────── let mut tts_was_active = false; - let mut ptt_was_active = ptt_active + let mut transmit_was_active = ptt_active .as_ref() - .is_some_and(|p| p.load(Ordering::Acquire)); + .is_some_and(|ptt| ptt.load(Ordering::Acquire)) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); loop { // Check shutdown flag before blocking. if shutdown.load(Ordering::Acquire) { @@ -292,20 +300,22 @@ fn stt_worker( } tts_was_active = tts_now; - // Track PTT transitions — flush accumulated speech when key is released. - // The worklet stops sending frames when PTT is inactive, so the normal - // silence-accumulation flush path never runs. We must flush here on the - // active→inactive edge to avoid buffering speech across PTT presses. + // Track the combined manual/PTT transmission edge. When both paths + // close, the worklet stops sending frames, so flush here rather than + // waiting for silence that will never arrive. if let Some(ref ptt) = ptt_active { - let ptt_now = ptt.load(Ordering::Acquire); - if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { + let transmit_now = ptt.load(Ordering::Acquire) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); + if transmit_was_active && !transmit_now && in_speech && !speech_buf.is_empty() { flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; voiced_frames = 0; } - ptt_was_active = ptt_now; + transmit_was_active = transmit_now; } // Use recv_timeout so we can periodically check the shutdown flag. @@ -343,6 +353,7 @@ fn stt_worker( &tts_active, &mut tts_stopped_at, ptt_active.as_ref(), + manual_mic_unmuted.as_ref(), ); } } @@ -385,11 +396,9 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, + manual_mic_unmuted: Option<&Arc>, ) { leftover.extend_from_slice(samples); @@ -413,13 +423,11 @@ fn process_16k_samples( let prob = vad.predict_f32(&clamped); let is_speech = prob > VAD_THRESHOLD; - // PTT gating: when PTT key is not held, treat as silence. - // This causes natural flush when the key is released — silence_frames - // accumulates and the existing flush logic kicks in after - // SILENCE_FLUSH_FRAMES. The 200 ms release delay + ~300 ms silence - // flush gives a natural utterance tail. + let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire)); + // Shortcut-enabled mode accepts input from either the held shortcut or + // a manually open microphone. let is_speech = if let Some(ptt) = ptt_active { - is_speech && ptt.load(Ordering::Acquire) + is_speech && (ptt.load(Ordering::Acquire) || manually_open) } else { is_speech }; @@ -478,11 +486,9 @@ fn process_16k_samples( speech_buf.extend_from_slice(&frame); *silence_frames += 1; - // In PTT mode, don't flush on silence — accumulate the entire - // key-hold as one utterance. The PTT release edge in the main - // loop handles the flush. In VAD mode, flush after the silence - // threshold so each natural pause becomes a separate message. - if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { + // A manually open microphone behaves like normal VAD. A + // shortcut-only transmission stays grouped until key release. + if (ptt_active.is_none() || manually_open) && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 1901bb3d2..6a56f8544 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -64,6 +64,11 @@ use audio::*; #[path = "tts_activity.rs"] mod activity; use activity::*; +#[path = "tts_pipeline_controls.rs"] +mod pipeline_controls; +#[path = "tts_speaker_cancellation.rs"] +mod speaker_cancellation; +use speaker_cancellation::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -126,7 +131,15 @@ const MAX_CHUNK_CHARS: usize = 200; /// Injected as a silent buffer between each synthesized sentence chunk. const INTER_SENTENCE_SILENCE: f32 = 0.1; -type WorkerControlState = (Arc, Arc, WorkerCancelSignals); +type WorkerControlState = ( + Arc, + Arc, + WorkerCancelSignals, + SpeakerGenerations, + ActiveSpeaker, + SpeakerCancellation, + PlaybackProbe, +); // ── Public pipeline handle ──────────────────────────────────────────────────── @@ -154,6 +167,15 @@ pub struct TtsPipeline { voice: Arc>, /// Tags messages so a voice change drops only pre-change queue entries. voice_generation: Arc, + /// Per-agent generations let removal invalidate that agent's queued and + /// in-flight text without poisoning speech queued after the agent rejoins. + speaker_generations: SpeakerGenerations, + /// Speaker whose audio currently owns the shared player queue. + active_speaker: ActiveSpeaker, + /// Targeted cancellation used when an agent leaves the huddle. + speaker_cancel: SpeakerCancellation, + /// Shared player handle used to reject Stop clicks after playback drains. + playback_probe: PlaybackProbe, /// Completed after the worker drains pre-change text and installs the new style. voice_change_ack: VoiceChangeAck, /// Worker thread handle — taken on drop to join cleanly. @@ -187,6 +209,14 @@ impl TtsPipeline { let voice_worker = Arc::clone(&voice); let voice_generation = Arc::new(AtomicU64::new(1)); let worker_voice_generation = Arc::clone(&voice_generation); + let speaker_generations = Arc::new(Mutex::new(HashMap::new())); + let worker_speaker_generations = Arc::clone(&speaker_generations); + let active_speaker = Arc::new(Mutex::new(None)); + let worker_active_speaker = Arc::clone(&active_speaker); + let speaker_cancel = Arc::new(Mutex::new(None)); + let worker_speaker_cancel = Arc::clone(&speaker_cancel); + let playback_probe = PlaybackProbe::new(); + let worker_playback_probe = playback_probe.clone(); let voice_change_ack = Arc::new(Mutex::new(None)); let worker_voice_change_ack = Arc::clone(&voice_change_ack); let model_dir_worker = model_dir.clone(); @@ -207,6 +237,10 @@ impl TtsPipeline { tts_active_worker, shutdown_worker, (cancel_worker, worker_voice_cancel), + worker_speaker_generations, + worker_active_speaker, + worker_speaker_cancel, + worker_playback_probe, ), output_device, activity_app, @@ -224,79 +258,14 @@ impl TtsPipeline { voice_cancel, voice, voice_generation, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, voice_change_ack, thread: Some(handle), }) } - - /// Queue `text` for TTS synthesis and playback. - /// - /// 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.text_tx - .try_send(QueuedText { - generation: self.voice_generation.load(Ordering::Acquire), - route_id: 0, - speaker_pubkey: None, - voice_reference: None, - text, - }) - .map_err(|e| { - eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); - format!("TTS queue full, dropping: {e}") - }) - } - - /// Clone the bounded queue sender so callers can apply backpressure without - /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks - /// any waiting sender while the shared cancellation flag stops playback. - pub(crate) fn text_sender(&self) -> TtsTextSender { - TtsTextSender { - text_tx: self.text_tx.clone(), - generation: self.voice_generation.load(Ordering::Acquire), - } - } - - /// Select a bundled Pocket voice for subsequent speech. - /// - /// Current playback and queued text are cancelled immediately so content - /// cannot continue in the old voice. The worker keeps its warmed inference - /// engine and reloads only the reference style before the next utterance. - pub fn select_voice(&self, voice: &str) -> Option> { - let acknowledged = begin_voice_change( - &self.voice, - &self.voice_generation, - &self.voice_cancel, - &self.voice_change_ack, - voice, - ); - if acknowledged.is_some() { - eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); - } - acknowledged - } - - /// Reconcile the voice of a pipeline that has not been published yet. - /// - /// No caller can enqueue text before publication, so raising the shared - /// cancellation flag here would create a race that could discard the first - /// message queued immediately after installation. - pub(crate) fn select_voice_before_publish(&self, voice: &str) { - *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); - } - - /// Signal the worker thread to stop. - pub fn shutdown(&self) { - eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); - self.shutdown.store(true, Ordering::Release); - } - - /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). - /// Used by hot-start to detect dead pipelines and clear them for retry. - pub fn is_finished(&self) -> bool { - self.thread.as_ref().is_none_or(|h| h.is_finished()) - } } impl Drop for TtsPipeline { @@ -322,7 +291,15 @@ fn tts_worker( startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; - let (tts_active, shutdown, cancel_signals) = control_state; + let ( + tts_active, + shutdown, + cancel_signals, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, + ) = control_state; let (cancel, voice_cancel) = cancel_signals; // ── 1. Initialise TTS engine ────────────────────────────────────────────── let model_dir_str = model_dir.to_string_lossy().to_string(); @@ -415,6 +392,7 @@ fn tts_worker( // Shared (Arc) with the barge-in monitor thread below, which needs to // silence it while this thread is blocked inside `synth_chunk`. let player = Arc::new(Player::connect_new(sink_handle.mixer())); + playback_probe.install(Arc::clone(&player)); // Prime the audio output stream with a short silent buffer. // On macOS, CoreAudio initializes the output device lazily on first use. @@ -443,103 +421,21 @@ fn tts_worker( } eprintln!("buzz-desktop: tts stage=startup status=ready"); - // ── 3b. Barge-in monitor thread ─────────────────────────────────────────── - // - // The worker loop only observes `cancel` between sentences — while it is - // blocked inside `synth_chunk` (hundreds of ms for a long sentence), - // nothing would silence the audio that is already playing. The monitor - // closes that gap: every MONITOR_TICK it checks the flag and, while set, - // silences the player and releases the mic gate. It does NOT consume the - // flag — the worker still owns that (drain queue, reset lead-in), so the - // monitor keeps re-clearing until the worker catches up, which also - // covers a sentence appended in the race window after the worker's own - // post-synthesis cancel check. - // - // `player_ops` closes the converse race (found in review): the monitor - // loads `cancel == true`, is preempted, the worker consumes the cancel - // and appends a fresh post-cancel utterance, then the monitor resumes - // from its stale branch and deletes audio that was meant to play. All - // worker player mutations (appends and cancel/shutdown clears) hold this - // lock, and the monitor re-checks `cancel` *while holding it* — so its - // clear either runs before fresh audio can be appended, or observes - // `cancel == false` and no-ops. The lock is uncontended except during an - // actual barge-in, so the hot path is unaffected. - let player_ops = Arc::new(Mutex::new(())); + let player_ops = Arc::clone(&playback_probe.player_ops); let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); - let monitor = { - let player = Arc::clone(&player); - let cancel = Arc::clone(&cancel); - let voice_cancel = Arc::clone(&voice_cancel); - let tts_active = Arc::clone(&tts_active); - let stop = Arc::clone(&monitor_stop); - let player_ops = Arc::clone(&player_ops); - let activity_frames = Arc::clone(&activity_frames); - thread::Builder::new() - .name("tts-barge-in-monitor".into()) - .spawn(move || { - let mut last_activity_pubkey: Option = None; - let mut next_activity_tick = Instant::now(); - while !stop.load(Ordering::Acquire) { - if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { - let _ops = lock_player_ops(&player_ops); - // Re-check under the lock: the worker may have - // consumed this cancel (and appended fresh audio) - // between the load above and the lock acquisition. - if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { - // clear() pauses the persistent player; play() - // un-pauses (see handle_cancel_or_shutdown). - // Idempotent — safe to repeat every tick until - // the worker consumes the flag. - player.clear(); - player.play(); - tts_active.store(false, Ordering::Release); - } - } - if let Some(ref app) = activity_app { - if tts_active.load(Ordering::Acquire) { - let now = Instant::now(); - if now >= next_activity_tick { - let frame = activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .pop_front(); - if let Some(frame) = frame { - use tauri::Emitter; - let _ = app.emit( - "huddle-tts-speaker-level", - TtsSpeakerActivityPayload { - pubkey: Some(frame.pubkey.clone()), - level: frame.level, - }, - ); - last_activity_pubkey = Some(frame.pubkey); - } - next_activity_tick = now + SPEAKER_ACTIVITY_TICK; - } - } else { - let had_activity = last_activity_pubkey.take().is_some(); - activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clear(); - if had_activity { - use tauri::Emitter; - let _ = app.emit( - "huddle-tts-speaker-level", - TtsSpeakerActivityPayload { - pubkey: None, - level: 0.0, - }, - ); - } - next_activity_tick = Instant::now(); - } - } - thread::sleep(MONITOR_TICK); - } - }) - }; + let monitor = spawn_tts_monitor(TtsMonitorState { + player: Arc::clone(&player), + cancel: Arc::clone(&cancel), + voice_cancel: Arc::clone(&voice_cancel), + tts_active: Arc::clone(&tts_active), + stop: Arc::clone(&monitor_stop), + player_ops: Arc::clone(&player_ops), + activity_frames: Arc::clone(&activity_frames), + active_speaker: Arc::clone(&active_speaker), + speaker_cancel: Arc::clone(&speaker_cancel), + activity_app, + }); if let Err(ref e) = monitor { // Degraded but functional: barge-in still works between sentences // via the worker's own checks, just not mid-synthesis. @@ -564,7 +460,8 @@ fn tts_worker( let mut deferred_text = VecDeque::new(); let append_audio = |prepared: PreparedModelAudio, route_id: u64, - speaker_pubkey: Option<&str>| { + speaker_pubkey: Option<&str>, + speaker_generation: u64| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -582,6 +479,30 @@ fn tts_worker( ); return false; } + let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| { + current_speaker_generation(&speaker_generations, pubkey) == speaker_generation + }); + if !speaker_is_current { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player.empty() { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + } if let Some(pubkey) = speaker_pubkey { activity_frames .lock() @@ -604,6 +525,17 @@ fn tts_worker( loop { let mut no_current_text = None; + if consume_speaker_cancel( + &speaker_cancel, + &active_speaker, + &speaker_generations, + &tts_active, + (&text_rx, &mut deferred_text, &mut no_current_text), + Some((&player, &player_ops)), + ) { + first_append = true; + continue; + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -647,6 +579,10 @@ fn tts_worker( // lead-in so the next utterance gets a fresh cushion. if player.empty() && !first_append { tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); eprintln!( "buzz-desktop: tts stage=player status=drained route_id={last_route_id}" ); @@ -679,6 +615,13 @@ fn tts_worker( let Some(queued_text) = queued_text else { continue; }; + if !queued_speaker_is_current(&speaker_generations, &queued_text) { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={}", + queued_text.route_id + ); + continue; + } if queued_text.generation < voice_generation.load(Ordering::Acquire) { eprintln!( "buzz-desktop: tts stage=queue status=dropped reason=voice_switch route_id={}", @@ -686,6 +629,22 @@ fn tts_worker( ); continue; } + if !player.empty() + && queued_text + .speaker_pubkey + .as_deref() + .is_some_and(|speaker| { + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| !active.eq_ignore_ascii_case(speaker)) + }) + { + deferred_text.push_front(queued_text); + thread::sleep(RECV_TIMEOUT); + continue; + } let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { selected_voice .lock() @@ -694,9 +653,32 @@ fn tts_worker( }); let raw_text = queued_text.text; let speaker_pubkey = queued_text.speaker_pubkey; + let speaker_generation = queued_text.speaker_generation; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); + // If playback already drained while we were waiting for this item, + // release stale ownership before doing any potentially slow voice or + // synthesis work. Serialize the drain decision with Stop and append so + // those paths observe one coherent utterance boundary. + { + let _ops = lock_player_ops(&player_ops); + if player.empty() && !first_append { + tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); + first_append = true; + } + } + + // From this point until the item finishes, an empty player can mean a + // voice-preparation or synthesis gap rather than a drained utterance. + // Stop must remain able to invalidate the in-flight speaker generation. + let _synthesis_flight = playback_probe.begin_synthesis(); + // The selected per-agent voice travels with the queue item, preserving // message order while allowing one warmed Pocket engine to alternate // between cached reference styles. @@ -714,20 +696,6 @@ fn tts_worker( continue; } - // If playback already drained while we were waiting for this item, - // the agent is silent — release the mic gate BEFORE preprocessing/ - // synthesis. Without this, an item arriving inside the recv timeout - // window would run the whole synthesis pass with `tts_active` stuck - // true and nothing playing, making STT discard human speech as - // "echo" during a silent window. (Pipelining is unaffected: when - // audio is still draining, `player.empty()` is false and the flag - // stays set across items.) - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); - first_append = true; - } - // Preprocess text. let text = preprocess_for_tts(&raw_text); if text.is_empty() { @@ -846,7 +814,12 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -872,7 +845,12 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs new file mode 100644 index 000000000..0ee472f0f --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs @@ -0,0 +1,100 @@ +use super::*; + +impl TtsPipeline { + /// Queue `text` for TTS synthesis and playback. + /// + /// 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.text_tx + .try_send(QueuedText { + generation: self.voice_generation.load(Ordering::Acquire), + route_id: 0, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text, + }) + .map_err(|e| { + eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); + format!("TTS queue full, dropping: {e}") + }) + } + + /// Clone the bounded queue sender so callers can apply backpressure without + /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks + /// any waiting sender while the shared cancellation flag stops playback. + pub(crate) fn text_sender(&self) -> TtsTextSender { + TtsTextSender { + text_tx: self.text_tx.clone(), + generation: self.voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::clone(&self.speaker_generations), + } + } + + /// Invalidate speech queued for one agent and cancel the player only when + /// that same agent currently owns it. + pub(crate) fn cancel_speaker(&self, speaker_pubkey: &str) { + request_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + speaker_pubkey, + ); + } + + /// Cancel exactly the speaker utterance currently owning playback. + /// + /// The speaker generation is advanced while ownership is locked, so a + /// stale Stop click cannot cancel a later utterance that starts after the + /// observed one drains. + pub(crate) fn cancel_active_speaker(&self, expected_speaker_pubkey: &str) -> bool { + request_active_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + &self.playback_probe, + expected_speaker_pubkey, + ) + } + + /// Select a bundled Pocket voice for subsequent speech. + /// + /// Current playback and queued text are cancelled immediately so content + /// cannot continue in the old voice. The worker keeps its warmed inference + /// engine and reloads only the reference style before the next utterance. + pub fn select_voice(&self, voice: &str) -> Option> { + let acknowledged = begin_voice_change( + &self.voice, + &self.voice_generation, + &self.voice_cancel, + &self.voice_change_ack, + voice, + ); + if acknowledged.is_some() { + eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); + } + acknowledged + } + + /// Reconcile the voice of a pipeline that has not been published yet. + /// + /// No caller can enqueue text before publication, so raising the shared + /// cancellation flag here would create a race that could discard the first + /// message queued immediately after installation. + pub(crate) fn select_voice_before_publish(&self, voice: &str) { + *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); + } + + /// Signal the worker thread to stop. + pub fn shutdown(&self) { + eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); + self.shutdown.store(true, Ordering::Release); + } + + /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). + /// Used by hot-start to detect dead pipelines and clear them for retry. + pub fn is_finished(&self) -> bool { + self.thread.as_ref().is_none_or(|h| h.is_finished()) + } +} diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs new file mode 100644 index 000000000..4b9c2824f --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs @@ -0,0 +1,177 @@ +use super::*; + +pub(super) struct TtsMonitorState { + pub(super) player: Arc, + pub(super) cancel: Arc, + pub(super) voice_cancel: Arc, + pub(super) tts_active: Arc, + pub(super) stop: Arc, + pub(super) player_ops: Arc>, + pub(super) activity_frames: Arc>>, + pub(super) active_speaker: ActiveSpeaker, + pub(super) speaker_cancel: SpeakerCancellation, + pub(super) activity_app: Option, +} + +pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result> { + thread::Builder::new() + .name("tts-barge-in-monitor".into()) + .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); + while !state.stop.load(Ordering::Acquire) { + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + let _ops = lock_player_ops(&state.player_ops); + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + state.player.clear(); + state.player.play(); + state + .active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + state.tts_active.store(false, Ordering::Release); + } + } + silence_cancelled_speaker( + &state.speaker_cancel, + &state.active_speaker, + &state.player, + &state.player_ops, + &state.tts_active, + ); + if let Some(ref app) = state.activity_app { + if state.tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = state + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + state + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } + thread::sleep(MONITOR_TICK); + } + }) +} + +pub(super) fn silence_cancelled_speaker( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + player: &rodio::Player, + player_ops: &Mutex<()>, + tts_active: &AtomicBool, +) { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + else { + return; + }; + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + } +} + +fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !active + .as_deref() + .is_some_and(|speaker| speaker.eq_ignore_ascii_case(cancelled)) + { + return false; + } + active.take(); + true +} + +pub(super) fn consume_speaker_cancel( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + generations: &SpeakerGenerations, + tts_active: &AtomicBool, + text_state: CancelTextState<'_>, + player: Option<(&rodio::Player, &Mutex<()>)>, +) -> bool { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + else { + return false; + }; + let (text_rx, deferred_text, current_text) = text_state; + retain_current_speaker_text(generations, deferred_text, current_text, text_rx); + let mut cleared_player = false; + if let Some((player, player_ops)) = player { + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + cleared_player = true; + } + } + // The monitor may already have cleared the cancelled speaker while the + // worker was blocked. If another speaker has since claimed the player, + // preserve that speaker's activity flag and lead-in state. + cleared_player +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_targeted_cancel_does_not_release_the_next_speaker() { + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + + assert!(!take_cancelled_active_speaker("alice", &active_speaker)); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob") + ); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 044b1acf1..bff5ab4f7 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -19,6 +19,10 @@ fn inert_pipeline(cancel: Arc) -> TtsPipeline { voice_cancel: Arc::new(AtomicBool::new(false)), voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())), voice_generation: Arc::new(AtomicU64::new(1)), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), + active_speaker: Arc::new(std::sync::Mutex::new(None)), + speaker_cancel: Arc::new(std::sync::Mutex::new(None)), + playback_probe: PlaybackProbe::new(), voice_change_ack: Arc::new(std::sync::Mutex::new(None)), thread: Some(thread), } @@ -168,6 +172,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: voice_generation.load(Ordering::Acquire), route_id: 1, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "new message".to_string(), }) @@ -181,6 +186,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: 1, route_id: 2, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "old message".to_string(), }, @@ -188,6 +194,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: voice_generation.load(Ordering::Acquire), route_id: 3, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "later new message".to_string(), }, @@ -246,6 +253,7 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { generation: voice_generation.load(Ordering::Acquire), route_id: 4, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "message for Eve".to_string(), }); @@ -294,6 +302,7 @@ fn barge_in_clears_deferred_voice_change_messages() { generation: 2, route_id: 5, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "deferred message".to_string(), }]); @@ -337,6 +346,7 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { generation: voice_generation.load(Ordering::Acquire), route_id: 6, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "post-change message".to_string(), }); @@ -365,6 +375,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() let old_sender = TtsTextSender { text_tx, generation: voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), }; let shutdown = AtomicBool::new(false); let active = AtomicBool::new(true); @@ -392,6 +403,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() .send( 7, "agent".to_string(), + 0, "reference_sample".to_string(), "late old message".to_string(), ) diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 3a6555375..a60d3506f 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, + fmt, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -19,6 +20,9 @@ pub(super) struct PendingVoiceChange { pub(super) type VoiceChangeAck = Arc>>; pub(super) type WorkerVoiceState = (Arc>, Arc, VoiceChangeAck); pub(super) type WorkerCancelSignals = (Arc, Arc); +pub(super) type SpeakerGenerations = Arc>>; +pub(super) type ActiveSpeaker = Arc>>; +pub(super) type SpeakerCancellation = Arc>>; pub(super) type CancelTextState<'a> = ( &'a mpsc::Receiver, &'a mut VecDeque, @@ -26,11 +30,73 @@ pub(super) type CancelTextState<'a> = ( ); pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); +#[derive(Clone)] +pub(super) struct PlaybackProbe { + player: Arc>>>, + pub(super) player_ops: Arc>, + synthesis_in_flight: Arc, +} + +pub(super) struct SynthesisFlightGuard { + playback_probe: PlaybackProbe, +} + +impl Drop for SynthesisFlightGuard { + fn drop(&mut self) { + self.playback_probe.set_synthesis_in_flight(false); + } +} + +impl PlaybackProbe { + pub(super) fn new() -> Self { + Self { + player: Arc::new(Mutex::new(None)), + player_ops: Arc::new(Mutex::new(())), + synthesis_in_flight: Arc::new(AtomicBool::new(false)), + } + } + + pub(super) fn install(&self, player: Arc) { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(player); + } + + pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) { + let _ops = super::lock_player_ops(&self.player_ops); + self.synthesis_in_flight.store(in_flight, Ordering::Release); + } + + pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard { + self.set_synthesis_in_flight(true); + SynthesisFlightGuard { + playback_probe: self.clone(), + } + } + + fn player(&self) -> Option> { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +impl fmt::Debug for PlaybackProbe { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackProbe") + .finish_non_exhaustive() + } +} + #[derive(Debug)] pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, pub(super) speaker_pubkey: Option, + pub(super) speaker_generation: u64, pub(super) voice_reference: Option, pub(super) text: String, } @@ -39,6 +105,7 @@ pub(super) struct QueuedText { pub(crate) struct TtsTextSender { pub(super) text_tx: SyncSender, pub(super) generation: u64, + pub(super) speaker_generations: SpeakerGenerations, } impl TtsTextSender { @@ -46,6 +113,7 @@ impl TtsTextSender { &self, route_id: u64, speaker_pubkey: String, + speaker_generation: u64, voice_reference: String, text: String, ) -> Result<(), String> { @@ -54,11 +122,156 @@ impl TtsTextSender { generation: self.generation, route_id, speaker_pubkey: Some(speaker_pubkey), + speaker_generation, voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } + + pub(crate) fn speaker_generation(&self, speaker_pubkey: &str) -> u64 { + current_speaker_generation(&self.speaker_generations, speaker_pubkey) + } +} + +pub(super) fn current_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + generations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&speaker_pubkey.to_ascii_lowercase()) + .copied() + .unwrap_or(0) +} + +pub(super) fn advance_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + let mut generations = generations + .lock() + .unwrap_or_else(|error| error.into_inner()); + let generation = generations + .entry(speaker_pubkey.to_ascii_lowercase()) + .or_default(); + *generation = generation.saturating_add(1); + *generation +} + +pub(super) fn queued_speaker_is_current( + generations: &SpeakerGenerations, + queued: &QueuedText, +) -> bool { + queued + .speaker_pubkey + .as_deref() + .is_none_or(|speaker_pubkey| { + current_speaker_generation(generations, speaker_pubkey) == queued.speaker_generation + }) +} + +pub(super) fn request_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + speaker_pubkey: &str, +) { + advance_speaker_generation(generations, speaker_pubkey); + let owns_player = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| active.eq_ignore_ascii_case(speaker_pubkey)); + if owns_player { + cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(speaker_pubkey.to_ascii_lowercase()); + } +} + +pub(super) fn request_active_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_probe: &PlaybackProbe, + expected_speaker_pubkey: &str, +) -> bool { + let Some(player) = playback_probe.player() else { + return false; + }; + let _ops = super::lock_player_ops(&playback_probe.player_ops); + let playback_live = + !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire); + request_active_speaker_cancel_while_locked( + generations, + active_speaker, + cancellation, + playback_live, + expected_speaker_pubkey, + ) +} + +fn request_active_speaker_cancel_while_locked( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_live: bool, + expected_speaker_pubkey: &str, +) -> bool { + if !playback_live { + return false; + } + let active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(speaker_pubkey) = active.as_deref() else { + return false; + }; + if !speaker_pubkey.eq_ignore_ascii_case(expected_speaker_pubkey) { + return false; + } + + // Keep ownership locked until the generation and cancellation request are + // committed. The drain path takes the same lock, so the request is bound + // to the utterance the Stop action actually observed. + let mut cancellation = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()); + if cancellation + .as_deref() + .is_some_and(|pending| pending.eq_ignore_ascii_case(speaker_pubkey)) + { + return false; + } + advance_speaker_generation(generations, speaker_pubkey); + cancellation.replace(speaker_pubkey.to_ascii_lowercase()); + true +} + +pub(super) fn retain_current_speaker_text( + generations: &SpeakerGenerations, + deferred_text: &mut VecDeque, + current_text: &mut Option, + text_rx: &mpsc::Receiver, +) { + deferred_text.retain(|text| queued_speaker_is_current(generations, text)); + if let Some(text) = current_text.take() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_front(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } + while let Ok(text) = text_rx.try_recv() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_back(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } } pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { @@ -258,3 +471,227 @@ 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}"); } + +#[cfg(test)] +mod speaker_generation_tests { + use super::*; + + fn playback_probe(playback_live: bool) -> PlaybackProbe { + let channels = std::num::NonZero::new(1).expect("non-zero channels"); + let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate"); + let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate); + let player = Arc::new(rodio::Player::connect_new(&mixer)); + if playback_live { + player.append(rodio::buffer::SamplesBuffer::new( + channels, + sample_rate, + vec![0.0; 24_000], + )); + } + let probe = PlaybackProbe::new(); + probe.install(player); + probe + } + + fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText { + QueuedText { + generation: 1, + route_id: 1, + speaker_pubkey: Some(speaker_pubkey.to_string()), + speaker_generation, + voice_reference: Some("pocket:mary".to_string()), + text: "Hello".to_string(), + } + } + + #[test] + fn removing_a_speaker_invalidates_only_that_speakers_queued_text() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let alice = queued_speech("ALICE", current_speaker_generation(&generations, "alice")); + let bob = queued_speech("bob", current_speaker_generation(&generations, "bob")); + + advance_speaker_generation(&generations, "alice"); + + assert!(!queued_speaker_is_current(&generations, &alice)); + assert!(queued_speaker_is_current(&generations, &bob)); + + let rejoined_alice = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &rejoined_alice)); + } + + #[test] + fn removing_a_silent_speaker_does_not_cancel_the_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "bob"); + + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice") + ); + } + + #[test] + fn targeted_cancellation_preserves_other_speakers_queue_entries() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let alice = queued_speech("alice", 0); + let bob = queued_speech("bob", 0); + let (_text_tx, text_rx) = mpsc::sync_channel(1); + let mut deferred = VecDeque::from([alice, bob]); + let mut current = None; + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "alice"); + retain_current_speaker_text(&generations, &mut deferred, &mut current, &text_rx); + + assert_eq!(deferred.len(), 1); + assert_eq!(deferred[0].speaker_pubkey.as_deref(), Some("bob")); + } + + #[test] + fn stop_request_is_bound_to_the_observed_speaker_generation() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice") + ); + + active_speaker.lock().expect("active speaker").take(); + cancellation.lock().expect("cancellation").take(); + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + + let next_utterance = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + } + + #[test] + fn stop_request_does_not_cancel_a_different_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert_eq!(current_speaker_generation(&generations, "bob"), 0); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob"), + ); + } + + #[test] + fn stop_request_during_empty_synthesis_gap_cancels_in_flight_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_chunk = queued_speech("alice", 0); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(!queued_speaker_is_current(&generations, &next_chunk)); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn repeated_stop_for_same_in_flight_utterance_is_idempotent() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + let speech_queued_after_first_stop = queued_speech("alice", 1); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(queued_speaker_is_current( + &generations, + &speech_queued_after_first_stop, + )); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn stop_request_after_playback_drains_preserves_queued_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_utterance = queued_speech("alice", 0); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(false), + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice"), + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f8e4e17fa..98720cd2c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -56,9 +56,10 @@ use huddle::reconnect::reconnect_huddle_audio; use huddle::{ add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, - get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window, - push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, - speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, + get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, + open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, + set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, + start_huddle, start_stt_pipeline, HuddlePhase, }; use initial_window::*; use managed_agents::{ @@ -588,7 +589,6 @@ pub fn run() { } }); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -716,6 +716,8 @@ pub fn run() { pick_and_upload_media, pick_and_upload_image, upload_media_bytes, + upload_media_bytes_raw, + cancel_media_upload, download_image, save_png_data_url, download_file, @@ -843,7 +845,9 @@ pub fn run() { huddle::agent_voice::set_huddle_agent_tts_enabled, huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, + interrupt_huddle_speech, add_agent_to_huddle, + remove_agent_from_huddle, huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, @@ -851,6 +855,7 @@ pub fn run() { get_huddle_agent_pubkeys, set_voice_input_mode, get_voice_input_mode, + set_huddle_manual_mic_unmuted, list_audio_output_devices, set_audio_output_device, get_audio_output_device, @@ -891,7 +896,6 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); #[cfg(unix)] 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..9956f19b2 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -77,6 +77,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", @@ -224,6 +227,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 a9f3be54f..c2e713399 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -33,7 +33,7 @@ mod runtime_types; pub(crate) mod scope; pub(crate) mod scope_init; 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/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 36028c923..6dbb77ca6 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -106,31 +106,6 @@ pub fn nest_dir() -> Option { } } -/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`). -/// -/// Pure function — no globals — so it can be unit-tested without touching the -/// process-lifetime [`NEST_DIR`] `OnceLock`. -fn path_is_dev_nest(path: &std::path::Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n == NEST_DIR_DEV) - .unwrap_or(false) -} - -/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`). -/// -/// This is `true` for all dev builds — `just staging` and `just dev` — because -/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data -/// directory starts with `"xyz.block.buzz.app.dev"`. -/// -/// Returns `false` when: -/// - The nest is the production nest (`~/.buzz`, signed DMG). -/// - [`init_nest_dir`] has not been called yet (unit tests, home dir -/// unresolvable) — the fallback path is always the prod nest. -pub fn nest_is_dev() -> bool { - nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false) -} - /// Creates the Buzz nest at `~/.buzz` if it doesn't already exist. /// /// Delegates to [`ensure_nest_at`] with the resolved nest directory. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a4..cbef171f6 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -923,39 +923,3 @@ fn refresh_skill_overwrites_on_version_bump() { "SKILL.md must be refreshed on version bump" ); } - -#[test] -fn test_path_is_dev_nest_dev_path_returns_true() { - let path = std::path::Path::new("/Users/someone/.buzz-dev"); - assert!( - path_is_dev_nest(path), - ".buzz-dev path must be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_prod_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz"); - assert!( - !path_is_dev_nest(path), - ".buzz path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_unrelated_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz-staging"); - assert!( - !path_is_dev_nest(path), - "unrelated path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_root_returns_false() { - let path = std::path::Path::new("/"); - assert!( - !path_is_dev_nest(path), - "root path must not be identified as dev nest" - ); -} 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 35087efea..f84771cbc 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -21,7 +21,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), } @@ -343,7 +345,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), } } @@ -439,7 +443,10 @@ pub async fn restore_managed_agents_on_launch( record.last_error = None; runtimes.insert( key, - super::ManagedAgentPairRuntime::starting(process, Some(scope.scope_id.clone())), + super::ManagedAgentPairRuntime::starting( + *process, + Some(scope.scope_id.clone()), + ), ); successfully_spawned.push(pubkey); } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8aeba81e..8c5b01fd5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,7 +22,8 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_session_title, runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, + DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -225,49 +226,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 +322,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 +343,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, @@ -496,10 +486,28 @@ pub(crate) fn spawn_agent_child_at( } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — + // 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 + // apply and this spawn could hand a fresh model/provider to a stale + // prompt. This also folds in orphan refusal via `require_resolved`: every + // caller (interactive start, launch restore, `start_managed_agent_process`) + // inherits it — no caller can bypass this by reaching `spawn_agent_child` + // directly. Checked before any side effect (log marker, log file, process + // spawn) so a refused spawn leaves no trace. let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config(record, personas, global) .require_resolved()?; + // 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, 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 = crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) .map_err(|e| { @@ -681,13 +689,23 @@ pub(crate) fn spawn_agent_child_at( } } } - 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 { command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } + // Prompt, model, and provider all come from the single `effective_cfg` + // 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 + // spawn semantics in lock-step (see `EffectiveAgentConfig::relay_mesh_model_id`). #[cfg(feature = "mesh-llm")] let mesh_model_id = effective_cfg.relay_mesh_model_id(); let effective_prompt = effective_cfg.system_prompt.value; @@ -704,11 +722,15 @@ pub(crate) fn spawn_agent_child_at( } else { command.env_remove("BUZZ_ACP_MODEL"); } - if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { - command.env(SESSION_TITLE_ENV_VAR, title); - } else { - command.env_remove(SESSION_TITLE_ENV_VAR); - } + // 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. The spawn-config snapshot records the same + // resolve, so a rename raises the restart badge instead of leaving the + // process stale. + apply_agent_display_env( + &mut command, + resolve_session_title(record.display_name.as_deref(), &record.name), + ); build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( @@ -783,6 +805,24 @@ pub(crate) fn spawn_agent_child_at( .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)] { use std::os::unix::process::CommandExt; @@ -803,9 +843,14 @@ pub(crate) fn spawn_agent_child_at( ) })?; - 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). + // Non-codex runtimes get `None` — nothing changes for them. + // When the cache is cold (e.g. Doctor just installed and cleared the cache), + // `adapter_availability_cached()` returns `None`, so the stamp is `None` and + // the drift check is skipped until discovery warms the cache — preventing a + // false restart badge immediately after auto-restart. let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { super::adapter_availability_cached() } else { @@ -816,7 +861,7 @@ pub(crate) fn spawn_agent_child_at( return Ok(super::process_lifecycle::finish_spawn( child, log_path, - spawn_config_hash, + spawn_config, spawned_setup_mode, spawned_adapter_availability, start_nonce, @@ -826,7 +871,7 @@ pub(crate) fn spawn_agent_child_at( 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..5aef424ea 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -25,8 +25,25 @@ 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"; +/// Stable agent display name forwarded to the ACP tool surface for git +/// attribution and private-conversation provenance. +pub(crate) const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME"; + +/// Apply the shared stable agent name to both session display metadata and +/// git attribution, clearing both keys when no usable name is available. +pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title: Option) { + if let Some(title) = title { + command + .env(SESSION_TITLE_ENV_VAR, &title) + .env(DISPLAY_NAME_ENV_VAR, title); + } else { + command + .env_remove(SESSION_TITLE_ENV_VAR) + .env_remove(DISPLAY_NAME_ENV_VAR); + } +} /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 99c0ab48c..088556110 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, None) } - -// ── 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/runtime_commands_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs index 166362678..27a7f3e60 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs @@ -171,7 +171,13 @@ fn make_exited_pair_runtime(scope_id: Option) -> ManagedAgentPairRuntime let process = super::super::ManagedAgentProcess { child, log_path: std::path::PathBuf::new(), - spawn_config_hash: 0, + spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record_with_relay(""), + &[], + &[], + "wss://relay.example", + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce".to_string(), @@ -197,7 +203,13 @@ fn make_live_pair_runtime() -> ManagedAgentPairRuntime { let process = super::super::ManagedAgentProcess { child, log_path: std::path::PathBuf::new(), - spawn_config_hash: 0, + spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record_with_relay(""), + &[], + &[], + "wss://relay.example", + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce".to_string(), 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 4e2ed82e1..47edbc559 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 2d5b56fc5..a22b1c735 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -9,8 +9,8 @@ use super::{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/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 6dcef0ecf..3f9fe49fe 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -213,7 +213,9 @@ struct TrayMenuState { pub enum TrayAction { NewChannel, OpenChannel { + #[serde(rename = "channelId")] channel_id: String, + #[serde(rename = "communityGeneration")] community_generation: u64, }, } @@ -614,6 +616,23 @@ pub fn update_tray_agent_activity( mod tests { use super::{requeue_actions, TrayAction, TrayActionQueue}; + #[test] + fn open_channel_action_serializes_with_frontend_field_names() { + let action = TrayAction::OpenChannel { + channel_id: "channel-123".into(), + community_generation: 7, + }; + + assert_eq!( + serde_json::to_value(action).expect("tray action should serialize"), + serde_json::json!({ + "kind": "openChannel", + "channelId": "channel-123", + "communityGeneration": 7, + }) + ); + } + #[test] fn stale_channel_actions_are_not_requeued_after_community_change() { let mut queue = TrayActionQueue { diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4bda55fd0..0d4417a75 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.4", + "version": "0.5.5", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 736dad1f6..29dcd26cd 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} + @@ -166,7 +166,7 @@ export function ProjectInboxDetailPane({ )} diff --git a/desktop/src/features/home/useHomeDrafts.ts b/desktop/src/features/home/useHomeDrafts.ts index 8d9accda6..4b4f4afa6 100644 --- a/desktop/src/features/home/useHomeDrafts.ts +++ b/desktop/src/features/home/useHomeDrafts.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { clearDraftEntry } from "@/features/messages/lib/useDrafts"; +import { deleteDraftEntry } from "@/features/messages/lib/useDrafts"; import { useActiveDraftCount, useDraftViewItems, @@ -62,7 +62,7 @@ export function useHomeDrafts({ const deleteDraft = React.useCallback( (draftKey: string) => { - clearDraftEntry(draftKey); + deleteDraftEntry(draftKey); if (selectedKey === draftKey) { setSelectedKey(null); } diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 88813a624..d63b669f1 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -65,6 +65,10 @@ function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); } +function interruptAgentSpeech(agentPubkey: string) { + return invoke("interrupt_huddle_speech", { agentPubkey }); +} + const HuddleContext = React.createContext(null); export function HuddleProvider({ @@ -99,10 +103,10 @@ export function HuddleProvider({ const clearHuddleError = React.useCallback(() => setHuddleError(null), []); const [micConnected, setMicConnected] = React.useState(false); const [isMuted, setIsMuted] = React.useState(false); - const micConnectedRef = React.useRef(micConnected); - micConnectedRef.current = micConnected; const isMutedRef = React.useRef(isMuted); isMutedRef.current = isMuted; + const micConnectedRef = React.useRef(micConnected); + micConnectedRef.current = micConnected; const [mirroredAudioState, setMirroredAudioState] = React.useState(null); const [mirroredMicLevel, setMirroredMicLevel] = React.useState(0); @@ -113,6 +117,12 @@ export function HuddleProvider({ setVoiceInputModeState, voiceInputMode, } = useHuddlePttState(micConnected); + // Manual mute remains independently controllable in every input mode. The + // PTT shortcut temporarily opens a manually muted microphone while held. + const locallyMuted = + isMuted && !(voiceInputMode === "push_to_talk" && pttActive); + const locallyMutedRef = React.useRef(locallyMuted); + locallyMutedRef.current = locallyMuted; /** Ephemeral channel ID — set after start_huddle/join_huddle, used for TTS subscription */ const [ephemeralChannelId, setEphemeralChannelId] = React.useState< string | null @@ -140,6 +150,9 @@ export function HuddleProvider({ const effectiveVoiceInputMode = ownsAudioSession ? voiceInputMode : (mirroredAudioState?.voiceInputMode ?? voiceInputMode); + const effectiveIsMuted = ownsAudioSession + ? locallyMuted + : (mirroredAudioState?.isMuted ?? true); const setSelectedDeviceId = React.useCallback( (deviceId: string) => { if (ownsAudioSession) { @@ -237,13 +250,20 @@ export function HuddleProvider({ [ownsAudioSession, setVoiceInputModeState], ); - // Ref-track the current audio track so disconnectMedia is stable (no - // dependency on localAudioTrack state). This prevents the unmount-cleanup - // effect from re-firing mid-startup when setLocalAudioTrack triggers a - // leaveHuddle dependency chain update. + // Keep disconnectMedia stable so setting the track cannot re-fire the + // unmount cleanup during startup. const audioTrackRef = React.useRef(null); audioTrackRef.current = localAudioTrack; + // Keep the browser track and worklet aligned with the combined manual/PTT + // state. The worklet tracks the manual state separately so a PTT release + // does not remute a microphone the user explicitly left open. + React.useEffect(() => { + if (!ownsAudioSession || !audioTrackRef.current) return; + audioTrackRef.current.enabled = !locallyMuted; + workletRef.current?.setTransmitting(!isMuted); + }, [isMuted, locallyMuted, ownsAudioSession]); + const toggleMute = React.useCallback(() => { if (!ownsAudioSession) { const nextMuted = !(mirroredAudioState?.isMuted ?? false); @@ -265,12 +285,19 @@ export function HuddleProvider({ return; } - setIsMuted((previous) => { - const next = !previous; - if (audioTrackRef.current) audioTrackRef.current.enabled = !next; - return next; + // Set the effective state promised by the button instead of inverting the + // hidden manual preference, which can differ while PTT is held. + const requestedMuted = !locallyMuted; + setIsMuted(requestedMuted); + void invoke("set_huddle_manual_mic_unmuted", { + enabled: !requestedMuted, }); - }, [mirroredAudioState?.isMuted, ownsAudioSession, voiceInputMode]); + }, [ + locallyMuted, + mirroredAudioState?.isMuted, + ownsAudioSession, + voiceInputMode, + ]); React.useEffect(() => { let cancelled = false; @@ -321,7 +348,7 @@ export function HuddleProvider({ if (!ownsAudioSession) return; const state: HuddleAudioMirrorState = { - isMuted, + isMuted: locallyMuted, micConnected, audioDevices: localAudioDevices, selectedDeviceId: localSelectedDeviceId, @@ -337,9 +364,9 @@ export function HuddleProvider({ if (event.payload.type === "set-muted") { const requestedMuted = event.payload.isMuted; setIsMuted(() => { - if (audioTrackRef.current) { - audioTrackRef.current.enabled = !requestedMuted; - } + void invoke("set_huddle_manual_mic_unmuted", { + enabled: !requestedMuted, + }); return requestedMuted; }); return; @@ -358,7 +385,7 @@ export function HuddleProvider({ return; } void emit(HUDDLE_AUDIO_STATE_EVENT, { - isMuted: isMutedRef.current, + isMuted: locallyMutedRef.current, micConnected: micConnectedRef.current, audioDevices: localAudioDevices, selectedDeviceId: localSelectedDeviceId, @@ -375,7 +402,7 @@ export function HuddleProvider({ unlisten?.(); }; }, [ - isMuted, + locallyMuted, localAudioDevices, localMicGain, localSelectedDeviceId, @@ -570,10 +597,11 @@ export function HuddleProvider({ setMicConnected(true); // Setup AudioWorklet — PCM goes to Rust via push_audio_pcm - const initialTransmitting = getVoiceInputMode() !== "push_to_talk"; + audioTrack.enabled = !locallyMutedRef.current; const worklet = await setupAudioWorklet( audioTrack, - initialTransmitting, + getVoiceInputMode(), + !isMutedRef.current, ); worklet.setGain(micGainRef.current); @@ -612,6 +640,8 @@ export function HuddleProvider({ tokenRef.current += 1; const myToken = tokenRef.current; + isMutedRef.current = false; + setIsMuted(false); setHuddleError(null); setIsStarting(true); onHuddleStartPendingChange?.(true); @@ -685,6 +715,8 @@ export function HuddleProvider({ busyRef.current = true; tokenRef.current += 1; const myToken = tokenRef.current; + isMutedRef.current = false; + setIsMuted(false); setHuddleError(null); setIsStarting(true); @@ -928,10 +960,9 @@ export function HuddleProvider({ micConnected: ownsAudioSession ? micConnected : (mirroredAudioState?.micConnected ?? false), - isMuted: ownsAudioSession - ? isMuted - : (mirroredAudioState?.isMuted ?? false), + isMuted: effectiveIsMuted, toggleMute, + interruptAgentSpeech, micLevel: ownsAudioSession ? micLevel : mirroredMicLevel, pttActive, voiceInputMode: effectiveVoiceInputMode, diff --git a/desktop/src/features/huddle/HuddleContext.types.ts b/desktop/src/features/huddle/HuddleContext.types.ts index e1e26f729..311366cb8 100644 --- a/desktop/src/features/huddle/HuddleContext.types.ts +++ b/desktop/src/features/huddle/HuddleContext.types.ts @@ -9,6 +9,8 @@ export interface HuddleContextValue { micConnected: boolean; isMuted: boolean; toggleMute: () => void; + /** Interrupt this agent only if it still owns the active utterance. */ + interruptAgentSpeech: (agentPubkey: string) => Promise; micLevel: number; pttActive: boolean; voiceInputMode: VoiceInputMode; diff --git a/desktop/src/features/huddle/components/AddAgentDialog.tsx b/desktop/src/features/huddle/components/AddAgentDialog.tsx index 7dd3a2075..2e403518d 100644 --- a/desktop/src/features/huddle/components/AddAgentDialog.tsx +++ b/desktop/src/features/huddle/components/AddAgentDialog.tsx @@ -1,20 +1,18 @@ import { invoke } from "@tauri-apps/api/core"; -import { Bot } from "lucide-react"; +import { LoaderCircle } from "lucide-react"; import * as React from "react"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { Dialog } from "@/shared/ui/dialog"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import type { ManagedAgentBackend } from "@/shared/api/types"; type ManagedAgentSummary = { pubkey: string; name: string; status: string; + avatar_url: string | null; + backend: ManagedAgentBackend; }; type AgentAddResult = { @@ -24,12 +22,14 @@ type AgentAddResult = { }; type AddAgentDialogProps = { + open: boolean; onClose: () => void; onAdd: (pubkey: string) => Promise; currentAgentPubkeys: string[]; }; export function AddAgentDialog({ + open, onClose, onAdd, currentAgentPubkeys, @@ -41,27 +41,68 @@ export function AddAgentDialog({ const [warning, setWarning] = React.useState(null); React.useEffect(() => { + if (!open) return; + let cancelled = false; + setAgents([]); + setLoading(true); + setAdding(null); + setError(null); + setWarning(null); + invoke("list_managed_agents") - .then(setAgents) + .then((nextAgents) => { + if (!cancelled) setAgents(nextAgents); + }) .catch((e: unknown) => { + if (cancelled) return; console.error("Failed to load agents:", e); setError("Could not load agents."); }) - .finally(() => setLoading(false)); - }, []); + .finally(() => { + if (!cancelled) setLoading(false); + }); - // Only show running agents that aren't already in the huddle. - const runningAgents = agents.filter( - (a) => a.status === "running" && !currentAgentPubkeys.includes(a.pubkey), + return () => { + cancelled = true; + }; + }, [open]); + + const availableAgents = agents.filter( + (agent) => + !currentAgentPubkeys.some( + (pubkey) => pubkey.toLowerCase() === agent.pubkey.toLowerCase(), + ), ); - async function handleAdd(pubkey: string) { + async function handleAdd(agent: ManagedAgentSummary) { if (adding) return; - setAdding(pubkey); + setAdding(agent.pubkey); setError(null); setWarning(null); + let startedForAdd = false; try { - const result = await onAdd(pubkey); + const isLocal = agent.backend.type === "local"; + const needsStart = isLocal + ? agent.status !== "running" + : agent.status !== "deployed"; + if (needsStart && isLocal) { + await invoke("start_managed_agent", { pubkey: agent.pubkey }); + startedForAdd = true; + } + const result = await onAdd(agent.pubkey); + if (needsStart && !isLocal) { + try { + await invoke("start_managed_agent", { pubkey: agent.pubkey }); + } catch (startError: unknown) { + const msg = + startError instanceof Error + ? startError.message + : String(startError); + setWarning(`Added to huddle, but could not start agent: ${msg}`); + console.error("Failed to start agent after huddle add:", startError); + return; + } + } if (result.parent_error) { // Agent was added to the ephemeral channel but parent channel add failed. // Show as a warning — don't close the dialog so the user can see it. @@ -72,6 +113,16 @@ export function AddAgentDialog({ onClose(); } } catch (e: unknown) { + if (startedForAdd) { + try { + await invoke("stop_managed_agent", { pubkey: agent.pubkey }); + } catch (rollbackError: unknown) { + console.error( + "Failed to stop agent after huddle add failed:", + rollbackError, + ); + } + } const msg = e instanceof Error ? e.message : String(e); setError(`Failed to add agent: ${msg}`); console.error("Failed to add agent to huddle:", e); @@ -85,76 +136,68 @@ export function AddAgentDialog({ onOpenChange={(open) => { if (!open) onClose(); }} - open + open={open} > - - - Add Agent to Huddle - - Select a running agent to join the huddle. - - + + {error ? ( +

+ {error} +

+ ) : null} -
- {error && ( -

- {error} -

- )} + {warning ? ( +

+ {warning} +

+ ) : null} - {warning && ( -
- {warning} - -
- )} - - {loading ? ( -

- Loading agents… -

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

- {agents.filter((a) => a.status === "running").length > 0 - ? "All running agents are already in this huddle." - : "No running agents found."} -

- ) : ( -
    - {runningAgents.map((agent) => ( + {loading ? ( +

    + Loading agents… +

    + ) : availableAgents.length === 0 ? ( +

    + All available agents are already in this huddle. +

    + ) : ( +
      + {availableAgents.map((agent) => { + const isAdding = adding === agent.pubkey; + return (
    • - ))} -
    - )} -
- -
- -
-
+ ); + })} + + )} + ); } diff --git a/desktop/src/features/huddle/components/AgentVoiceMenu.tsx b/desktop/src/features/huddle/components/AgentVoiceMenu.tsx index d1dd2bba1..a44974f7a 100644 --- a/desktop/src/features/huddle/components/AgentVoiceMenu.tsx +++ b/desktop/src/features/huddle/components/AgentVoiceMenu.tsx @@ -7,6 +7,7 @@ import { voicesForBackend, } from "@/features/settings/ui/voiceSettingsLogic"; import { invokeTauri } from "@/shared/api/tauri"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -26,18 +27,28 @@ export type HuddleAgentVoiceSettings = { type AgentVoiceMenuProps = { agentPubkey: string; + contentAlign?: React.ComponentProps["align"]; + contentClassName?: string; + contentSide?: React.ComponentProps["side"]; displayName: string; + onRemoveAgent?: () => void; registry: VoiceRegistryEntry[]; settings: HuddleAgentVoiceSettings | undefined; onSettingsChange: (settings: HuddleAgentVoiceSettings) => void; + trigger?: React.ReactElement; }; export function AgentVoiceMenu({ agentPubkey, + contentAlign = "end", + contentClassName, + contentSide, displayName, + onRemoveAgent, registry, settings, onSettingsChange, + trigger, }: AgentVoiceMenuProps) { const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); @@ -74,18 +85,26 @@ export function AgentVoiceMenu({ return ( - + {trigger ?? ( + + )} - +
); diff --git a/desktop/src/features/huddle/lib/audioWorklet.ts b/desktop/src/features/huddle/lib/audioWorklet.ts index 677f4ac0a..ae90aee6e 100644 --- a/desktop/src/features/huddle/lib/audioWorklet.ts +++ b/desktop/src/features/huddle/lib/audioWorklet.ts @@ -19,10 +19,9 @@ function invokeRawBinary(cmd: string, payload: Uint8Array): Promise { /** Return type for setupAudioWorklet — stop + mode control. */ export type AudioWorkletHandle = { stop: () => void; - /** Send PTT state to the worklet processor. */ + /** Update whether the microphone is manually unmuted. */ setTransmitting: (active: boolean) => void; - /** Switch voice input mode. In VAD mode, always transmitting (PTT events ignored). - * In PTT mode, gated by Ctrl+Space. */ + /** Switch whether the push-to-talk shortcut participates in transmission. */ setMode: (mode: "push_to_talk" | "voice_activity") => void; /** Set mic input gain (0–1). Adjusts the GainNode between source and worklet. */ setGain: (value: number) => void; @@ -40,17 +39,19 @@ export type AudioWorkletHandle = { * → invokeRawBinary("push_audio_pcm", bytes) * Rust: SttPipeline::push_audio → bounded sync_channel * - * PTT gating: - * Main thread listens for Tauri "ptt-state" events (from Rust global shortcut) - * and forwards them to the worklet via port.postMessage({ type: 'ptt', active }). - * The worklet discards audio frames when transmitting=false. + * Transmission gating: + * Main thread combines manual mute with Tauri "ptt-state" events from the + * global shortcut. The worklet sends audio while either path is open and + * discards frames only when both are closed. * * @param audioTrack - Mic track from LiveKit - * @param initialTransmitting - Initial PTT state. true=open mic (VAD), false=muted until PTT press. + * @param initialMode - Whether the push-to-talk shortcut is enabled. + * @param initiallyManuallyUnmuted - Initial state of the clickable mic control. */ export async function setupAudioWorklet( audioTrack: MediaStreamTrack, - initialTransmitting = true, + initialMode: "push_to_talk" | "voice_activity" = "voice_activity", + initiallyManuallyUnmuted = true, ): Promise { const audioContext = new AudioContext({ sampleRate: 48000 }); @@ -74,11 +75,17 @@ export async function setupAudioWorklet( source.connect(gainNode); gainNode.connect(workletNode); - // Set initial PTT state (worklet defaults to transmitting=true). - // In PTT mode, immediately gate audio until the user presses the key. - if (!initialTransmitting) { - workletNode.port.postMessage({ type: "ptt", active: false }); - } + let currentMode = initialMode; + let shortcutActive = false; + let manuallyUnmuted = initiallyManuallyUnmuted; + const syncTransmission = () => { + workletNode.port.postMessage({ + type: "ptt", + active: + manuallyUnmuted || (currentMode === "push_to_talk" && shortcutActive), + }); + }; + syncTransmission(); // Forward PCM batches to Rust via raw binary invoke. // Direction: worklet→main (receives PCM data from worklet processor). @@ -96,22 +103,16 @@ export async function setupAudioWorklet( }); }; - // Track the current mode so PTT events are only forwarded in PTT mode. - // In VAD mode, the worklet stays in transmitting=true regardless of - // Ctrl+Space presses — prevents accidental muting. (Crossfire fix I1.) - let currentMode: "push_to_talk" | "voice_activity" = initialTransmitting - ? "voice_activity" - : "push_to_talk"; - // Listen for PTT state from Rust global shortcut (Ctrl+Space press/release). // Direction: Rust→main→worklet. The Tauri event carries a boolean payload. let pttUnlisten: UnlistenFn | null = null; try { pttUnlisten = await listen("ptt-state", (event) => { // Only forward PTT events to the worklet when in PTT mode. - // In VAD mode, Ctrl+Space is ignored — the worklet stays open. + // Manual unmute remains independent from the shortcut state. if (currentMode === "push_to_talk") { - workletNode.port.postMessage({ type: "ptt", active: event.payload }); + shortcutActive = event.payload; + syncTransmission(); } }); } catch { @@ -130,16 +131,13 @@ export async function setupAudioWorklet( void audioContext.close(); }, setTransmitting: (active: boolean) => { - workletNode.port.postMessage({ type: "ptt", active }); + manuallyUnmuted = active; + syncTransmission(); }, setMode: (mode: "push_to_talk" | "voice_activity") => { currentMode = mode; - // When switching to VAD, immediately open the mic. - // When switching to PTT, immediately gate until key press. - workletNode.port.postMessage({ - type: "ptt", - active: mode === "voice_activity", - }); + shortcutActive = false; + syncTransmission(); }, setGain: (value: number) => { gainNode.gain.value = value; diff --git a/desktop/src/features/huddle/lib/useHuddlePttState.ts b/desktop/src/features/huddle/lib/useHuddlePttState.ts index 16010b59a..04a3b4e7f 100644 --- a/desktop/src/features/huddle/lib/useHuddlePttState.ts +++ b/desktop/src/features/huddle/lib/useHuddlePttState.ts @@ -7,8 +7,8 @@ export type VoiceInputMode = "push_to_talk" | "voice_activity"; export function useHuddlePttState(micConnected: boolean) { const [pttActive, setPttActive] = React.useState(false); const [voiceInputMode, setVoiceInputModeState] = - React.useState("voice_activity"); - const voiceInputModeRef = React.useRef("voice_activity"); + React.useState("push_to_talk"); + const voiceInputModeRef = React.useRef("push_to_talk"); voiceInputModeRef.current = voiceInputMode; const getVoiceInputMode = React.useCallback( () => voiceInputModeRef.current, @@ -20,7 +20,7 @@ export function useHuddlePttState(micConnected: boolean) { invoke("get_voice_input_mode") .then(setVoiceInputModeState) .catch(() => { - /* best-effort — default is voice_activity */ + /* best-effort — default is push_to_talk */ }); }, []); @@ -29,8 +29,10 @@ export function useHuddlePttState(micConnected: boolean) { let unlisten: (() => void) | null = null; listen("ptt-state", (event) => { if (cancelled) return; - setPttActive(event.payload); - if (!micConnected) return; + const acceptsPtt = + micConnected && voiceInputModeRef.current === "push_to_talk"; + setPttActive(acceptsPtt ? event.payload : false); + if (!acceptsPtt) return; try { if ( !pttAudioCtxRef.current || @@ -64,6 +66,12 @@ export function useHuddlePttState(micConnected: boolean) { }; }, [micConnected]); + React.useEffect(() => { + if (!micConnected || voiceInputMode !== "push_to_talk") { + setPttActive(false); + } + }, [micConnected, voiceInputMode]); + return { getVoiceInputMode, pttActive, diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 000000000..53748e7d8 --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,85 @@ +/** + * Persists whether the user has made an explicit choice about the + * observer-frame archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on community switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + * + * Storage-error contract: a single read that throws is treated the same as + * a stored "1" (treat-as-set, fail-closed). This matches the metric-archive + * path: a storage error must never cause the seeding guard to fire or allow + * a stored opt-out to be silently overridden. + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Reads the stored explicit choice for this identity in a single localStorage + * access. + * + * Returns: + * `false` — user explicitly opted out ("0" stored) + * `true` — user explicitly opted in ("1" stored) + * `"unset"` — no choice recorded yet + * + * On storage error, returns `true` (fail-closed: treat as already opted in, + * suppress auto-seeding, and never override a potentially stored opt-out). + */ +export function readExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean | "unset" { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + const raw = window.localStorage.getItem(storageKey(identityPubkey)); + if (raw === null) return "unset"; + return raw !== "0"; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitObserverArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (merge_save_subscription_kinds is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitObserverArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index fbf8c506e..f7dd331c2 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -25,8 +25,8 @@ import { SettingsOptionRow, } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; -import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; +import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; import { buildSubscriptionRequest, @@ -66,18 +66,16 @@ function kindSummary(kinds: number[]): string { type ObserverSectionProps = { enabled: boolean; - policy: boolean | undefined; toggling: boolean; onToggle: (checked: boolean) => void; }; function ObserverArchiveSection({ enabled, - policy, toggling, onToggle, }: ObserverSectionProps) { - const toggleDisabled = toggling || policy === undefined || policy === true; + const toggleDisabled = toggling; return (

@@ -93,9 +91,7 @@ function ObserverArchiveSection({ Archive my agents' observer frames

- {policy === true - ? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.` - : `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`} + {`Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}

(undefined); - - React.useEffect(() => { - observerArchiveDefaultEnabled() - .then((on) => setObserverPolicy(on)) - .catch(() => { - // Fail closed: leave as undefined so toggle stays disabled. - }); - }, []); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -465,7 +450,6 @@ export function LocalArchiveSettingsCard() { const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; - if (!checked && observerPolicy !== false) return; setObserverToggling(true); try { if (checked) { @@ -473,6 +457,7 @@ export function LocalArchiveSettingsCard() { } else { await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME); } + setExplicitObserverArchiveChoice(pubkey, checked); toast.success( checked ? "Observer feed archive enabled." @@ -489,7 +474,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, observerPolicy, reload], + [pubkey, reload], ); const handleMetricToggle = React.useCallback( @@ -539,7 +524,6 @@ export function LocalArchiveSettingsCard() { void handleObserverToggle(checked)} - policy={observerPolicy} toggling={observerToggling} /> diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs index 6a832b4bd..e19b80a7f 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs @@ -1,8 +1,8 @@ /** * Tests for useAgentMetricArchiveSeed seeding logic. * - * Mirrors the pattern in useObserverArchiveSeed.test.mjs — drives the async - * seed logic via the deps-injection interface, no React required. + * Archive defaults to enabled for all builds. The seed fires for any identity + * without an explicit prior choice. */ import assert from "node:assert/strict"; @@ -10,16 +10,11 @@ import test from "node:test"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - defaultOn = false, - hasExplicitChoice = false, - mergeShouldFail = false, -} = {}) { +function makeDeps({ hasExplicitChoice = false, mergeShouldFail = false } = {}) { const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; return { calls, - agentMetricArchiveDefaultEnabled: async () => defaultOn, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.mergeSaveSubscriptionKinds.push({ kind }); @@ -39,15 +34,6 @@ async function runSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - - if (!defaultOn) return; - try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch { @@ -59,8 +45,8 @@ async function runSeed(pubkey, deps) { // ── Tests ──────────────────────────────────────────────────────────────────── -test("test_internal_build_unset_seeds_owner_p_subscription", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -72,8 +58,8 @@ test("test_internal_build_unset_seeds_owner_p_subscription", async () => { assert.equal(call.kind, 44200); }); -test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -86,7 +72,7 @@ test("test_internal_build_unset_persists_explicit_choice_after_seed", async () = }); test("test_explicit_choice_set_does_not_reseed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + const deps = makeDeps({ hasExplicitChoice: true }); await runSeed("pubkey123", deps); assert.equal( @@ -101,25 +87,8 @@ test("test_explicit_choice_set_does_not_reseed", async () => { ); }); -test("test_oss_build_does_not_seed", async () => { - const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); - - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 0, - "should not call mergeSaveSubscriptionKinds in OSS build", - ); - assert.equal( - deps.calls.setExplicitChoice.length, - 0, - "should not persist explicit choice in OSS build", - ); -}); - test("test_merge_failure_does_not_persist_explicit_choice", async () => { const deps = makeDeps({ - defaultOn: true, hasExplicitChoice: false, mergeShouldFail: true, }); @@ -133,7 +102,7 @@ test("test_merge_failure_does_not_persist_explicit_choice", async () => { }); test("test_empty_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("", deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -141,7 +110,7 @@ test("test_empty_pubkey_does_nothing", async () => { }); test("test_undefined_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed(undefined, deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -150,18 +119,9 @@ test("test_undefined_pubkey_does_nothing", async () => { // ── Concurrent-interleave test ─────────────────────────────────────────────── // -// Verifies the scenario Paul identified: on an internal-build first run with -// both flags on and no prior owner_p row, the observer and metric seeds race. -// With the old TS-side list+merge+create pattern the interleave could be: -// -// 1. observer seed: await list() → [] -// 2. metric seed: await list() → [] (row not yet written) -// 3. observer writes [24200] -// 4. metric writes [44200] → clobbers 24200 -// -// The new pattern delegates the merge to Rust under a single SQLite tx. -// Here we model that by tracking a shared "db state" and verifying that -// running both seeds concurrently (Promise.all) leaves both kinds present. +// Verifies the scenario where both observer and metric seeds race on first +// run. With the atomic merge, running both seeds concurrently leaves both +// kinds present. test("test_concurrent_seeds_both_kinds_survive", async () => { // Shared in-memory "db" — the atomic merge impl would serialize via SQLite @@ -169,9 +129,8 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { // the final state. const db = new Set(); // kinds present after all merges - function makeConcurrentDeps(defaultOn = true) { + function makeConcurrentDeps() { return { - agentMetricArchiveDefaultEnabled: async () => defaultOn, // Simulates the atomic merge: each call simply adds its kind to the set, // regardless of what was there before (atomicity guarantee). mergeSaveSubscriptionKinds: async (kind) => { @@ -189,13 +148,6 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { async function runObserverSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - if (!defaultOn) return; try { await deps.mergeSaveSubscriptionKinds(24200); } catch { diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts index 520d0e660..2cadaa260 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts @@ -1,27 +1,23 @@ /** * First-run seeding for agent-turn-metric archive. * - * When an internal build has `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` set and - * the current identity has not yet made an explicit choice, this hook - * auto-creates an `owner_p` save subscription including kind 44200 agent turn - * metrics, scoped to the current identity's pubkey. + * Archive defaults to enabled for all builds. When the current identity has + * not yet made an explicit choice, this hook auto-creates an `owner_p` save + * subscription including kind 44200 agent turn metrics, scoped to the current + * identity's pubkey. * * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently * running observer seed (24200) cannot clobber this kind — the union happens * under a single SQLite transaction regardless of await ordering. * - * OSS builds return `false` from `agent_metric_archive_default_enabled` → - * no-op. After any explicit user action (seeding or opt-out), the localStorage - * flag prevents re-seeding on subsequent starts. + * After any explicit user action (seeding or opt-out), the localStorage flag + * prevents re-seeding on subsequent starts. */ import * as React from "react"; import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; -import { - mergeSaveSubscriptionKinds, - agentMetricArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { hasExplicitAgentMetricArchiveChoice, setExplicitAgentMetricArchiveChoice, @@ -31,14 +27,12 @@ import { * Deps interface for testing. Production callers pass nothing. */ export interface AgentMetricArchiveSeedDeps { - agentMetricArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; hasExplicitChoice: (pubkey: string) => boolean; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: AgentMetricArchiveSeedDeps = { - agentMetricArchiveDefaultEnabled, mergeSaveSubscriptionKinds, hasExplicitChoice: hasExplicitAgentMetricArchiveChoice, setExplicitChoice: setExplicitAgentMetricArchiveChoice, @@ -46,7 +40,7 @@ const defaultDeps: AgentMetricArchiveSeedDeps = { /** * Seed the agent-turn-metric archive subscription for `pubkey` once per - * identity per device on internal builds. + * identity per device. * * @param pubkey - current identity pubkey. When undefined (identity not yet * loaded), the hook waits until it becomes available. @@ -69,23 +63,7 @@ export function useAgentMetricArchiveSeed( // boundary — re-guard here so the call below is type-safe. if (!pubkey) return; - let defaultOn: boolean; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch (err) { - console.warn("[useAgentMetricArchiveSeed] flag check failed:", err); - return; - } - - if (cancelled) return; - - if (!defaultOn) { - // OSS build (flag off): don't persist a choice — leave null so seeding - // can still fire if this identity later runs an internal build. - return; - } - - // Internal build + no prior choice → auto-seed via atomic DB merge. + // Auto-seed via atomic DB merge. try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch (err) { diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index ccd249892..2cf8df00a 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,23 +10,29 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - policyOn = false, - mergeShouldFail = false, - flagShouldFail = false, -} = {}) { +function makeDeps({ mergeShouldFail = false, explicitChoice = "unset" } = {}) { const calls = { merge: [] }; + // Simulates a per-pubkey localStorage map. "unset" means no choice stored. + const choices = new Map(); + if (explicitChoice !== "unset") { + // Pre-populate a choice for any pubkey that asks (single-pubkey tests). + choices.set("__default__", explicitChoice); + } return { calls, - observerArchiveDefaultEnabled: async () => { - if (flagShouldFail) throw new Error("flag check failed"); - return policyOn; - }, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.merge.push({ kind }); }, + readExplicitChoice: (pubkey) => { + if (choices.has(pubkey)) return choices.get(pubkey); + if (choices.has("__default__")) return choices.get("__default__"); + return "unset"; + }, + setExplicitChoice: (pubkey, enabled) => { + choices.set(pubkey, enabled); + }, }; } @@ -35,50 +41,111 @@ function tick() { return new Promise((r) => setTimeout(r, 0)); } -// ── Internal policy build ──────────────────────────────────────────────────── +// ── Reconciliation always seeds 24200 ──────────────────────────────────────── -test("test_internal_policy_seeds_24200", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); +test("test_reconcile_always_seeds_24200", async () => { + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200); }); -// ── OSS build — policy-off is a pure no-op ────────────────────────────────── - -test("test_oss_policy_off_no_merge", async () => { - const deps = makeDeps({ policyOn: false }); - await reconcileObserverArchive(deps); - - assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); -}); - // ── Failure behavior ───────────────────────────────────────────────────────── test("test_merge_failure_rejects", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); - await assert.rejects(() => reconcileObserverArchive(deps), { + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { message: "merge failed", }); }); -test("test_flag_check_failure_rejects", async () => { - const deps = makeDeps({ flagShouldFail: true }); +// ── Explicit opt-out survives restart ──────────────────────────────────────── - await assert.rejects(() => reconcileObserverArchive(deps), { - message: "flag check failed", - }); - assert.equal(deps.calls.merge.length, 0); +test("test_reconcile_explicit_optout_skips_merge", async () => { + // Simulate a user who explicitly opted out (choice stored as false). + const deps = makeDeps({ explicitChoice: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when user has explicitly opted out", + ); +}); + +test("test_reconcile_storage_error_treated_as_fail_closed", async () => { + // Storage errors return `true` (not "unset"), which means reconcile + // treats them as an already-set choice and skips the merge. + // This prevents auto-seeding from silently overriding a stored opt-out + // that we couldn't read due to the error. + const deps = makeDeps(); + // Override readExplicitChoice to simulate a storage error returning `true`. + deps.readExplicitChoice = () => true; + + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when storage error returns fail-closed true", + ); +}); + +test("test_reconcile_explicit_optin_already_seeded_skips_merge", async () => { + // Simulate a user who already has an explicit opt-in recorded (already + // seeded on a prior run). The new tri-state model skips merge for any + // non-"unset" choice — re-merging is idempotent but wasteful. + const deps = makeDeps({ explicitChoice: true }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when choice is already recorded as opted-in", + ); +}); + +test("test_reconcile_no_prior_choice_seeds_and_records_choice", async () => { + const deps = makeDeps(); // no explicitChoice set + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 1, "merge must fire on first run"); + // After reconciliation the choice should now be recorded as true. + assert.equal( + deps.readExplicitChoice("pk1"), + true, + "stored choice must be true after seed", + ); +}); + +test("test_reconcile_toggle_off_then_restart_does_not_remerge", async () => { + // This is the exact failure mode Paul described: + // 1. User toggles OFF → setExplicitChoice("pk1", false) + // 2. App restarts → reconcileObserverArchive runs again + // 3. Expected: merge is NOT called (opt-out preserved) + const deps = makeDeps(); + + // Simulate the card's handleObserverToggle(false) path: explicit opt-out stored. + deps.setExplicitChoice("pk1", false); + + // Simulate app restart — reconciliation fires. + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire after explicit opt-out on app restart", + ); }); // ── Startup ordering (real ArchiveSyncManager + real reconciler) ───────────── test("test_archive_sync_blocked_until_reconciliation", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const subscribeCalls = []; @@ -90,8 +157,9 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }; const reconcilerDeps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const manager = new ArchiveSyncManager({ @@ -110,8 +178,8 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { onSubscriptionChange: () => () => {}, }); - // Start reconciliation (pending — flag check not yet resolved). - const reconciling = reconcileObserverArchive(reconcilerDeps); + // Start reconciliation (pending — merge not yet resolved). + const reconciling = reconcileObserverArchive("pk1", reconcilerDeps); // Before reconciliation resolves, manager must not have been started. await tick(); @@ -122,7 +190,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { ); // Resolve reconciliation — now start the manager (simulating the gate). - resolveFlag(true); + resolveMerge(); await reconciling; await manager.start(); @@ -139,7 +207,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }); test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { - const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const reconcilerDeps = makeDeps({ mergeShouldFail: true }); const subscribeCalls = []; const fakeRelay = { @@ -168,7 +236,7 @@ test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { // Reconciliation rejects — gate must remain closed. let rejected = false; try { - await reconcileObserverArchive(reconcilerDeps); + await reconcileObserverArchive("pk1", reconcilerDeps); } catch { rejected = true; } @@ -207,8 +275,8 @@ test("test_identity_change_resets_readiness", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; assert.equal( isReconciledFor(reconciledPubkey, "pkA"), @@ -224,8 +292,8 @@ test("test_identity_change_resets_readiness", async () => { ); // B reconciles successfully. - const depsB = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsB); + const depsB = makeDeps(); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; assert.equal( isReconciledFor(reconciledPubkey, "pkB"), @@ -243,14 +311,14 @@ test("test_identity_change_b_failure_stays_closed", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; // Identity changes to B — B's reconciliation fails. - const depsB = makeDeps({ policyOn: true, mergeShouldFail: true }); + const depsB = makeDeps({ mergeShouldFail: true }); try { - await reconcileObserverArchive(depsB); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; } catch { // B failed — reconciledPubkey stays "pkA" (stale). @@ -272,7 +340,7 @@ test("test_identity_change_b_failure_stays_closed", async () => { // re-running an effect with new deps (identity switch). test("test_startReconciliation_calls_onReady_after_success", async () => { - const deps = makeDeps({ policyOn: true }); + const deps = makeDeps(); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -283,13 +351,14 @@ test("test_startReconciliation_calls_onReady_after_success", async () => { }); test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const deps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const readyCalls = []; @@ -297,9 +366,9 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async readyCalls.push(pubkey), ); - // Unmount (or re-run effect) before the flag check resolves. + // Unmount (or re-run effect) before the merge resolves. cancel(); - resolveFlag(true); + resolveMerge(); await tick(); assert.deepEqual( @@ -310,15 +379,16 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async }); test("test_startReconciliation_identity_switch_stale_completion_suppressed", async () => { - let resolveFlagA; - const flagPromiseA = new Promise((resolve) => { - resolveFlagA = resolve; + let resolveMergeA; + const mergePromiseA = new Promise((resolve) => { + resolveMergeA = resolve; }); const depsA = { - observerArchiveDefaultEnabled: () => flagPromiseA, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromiseA, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; - const depsB = makeDeps({ policyOn: true }); + const depsB = makeDeps(); const readyCalls = []; const onReady = (pubkey) => readyCalls.push(pubkey); @@ -330,8 +400,8 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy cancelA(); startReconciliation("pkB", depsB, onReady); - // A's flag check now resolves late — its stale completion must not fire. - resolveFlagA(true); + // A's merge now resolves late — its stale completion must not fire. + resolveMergeA(); await tick(); assert.deepEqual( @@ -342,7 +412,7 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy }); test("test_startReconciliation_failure_does_not_call_onReady", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -354,8 +424,8 @@ test("test_startReconciliation_failure_does_not_call_onReady", async () => { // ── Metric seed independence ───────────────────────────────────────────────── test("test_metric_seed_remains_independently_deferrable", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200"); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 7fc87f010..6a70ed209 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -1,40 +1,46 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { - mergeSaveSubscriptionKinds, - observerArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; + readExplicitObserverArchiveChoice, + setExplicitObserverArchiveChoice, +} from "./observerArchivePreference"; export interface ObserverArchiveSeedDeps { - observerArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; + readExplicitChoice: (pubkey: string) => boolean | "unset"; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { - observerArchiveDefaultEnabled, mergeSaveSubscriptionKinds, + readExplicitChoice: readExplicitObserverArchiveChoice, + setExplicitChoice: setExplicitObserverArchiveChoice, }; /** * Reconcile observer-feed archive state for the current identity. * - * Internal builds (policy flag ON): unconditionally ensure kind 24200 exists - * in the DB subscription. - * - * OSS builds (policy flag OFF): no-op. The Settings toggle is the only - * mutation path for OSS users. + * Archive defaults to enabled for all builds. Merges kind 24200 into the + * DB subscription via an atomic DB-side merge — UNLESS the user has + * previously made an explicit opt-out choice for this identity, in which + * case we skip the merge to preserve their preference across restarts. * * Rejects on failure — callers must not open archive listeners against * unreconciled state. */ export async function reconcileObserverArchive( + pubkey: string, deps: ObserverArchiveSeedDeps = defaultDeps, ): Promise { - const policyOn = await deps.observerArchiveDefaultEnabled(); - if (!policyOn) return; - + const choice = deps.readExplicitChoice(pubkey); + // Any explicit choice (or a storage error treated as fail-closed) skips the + // merge: opted-out users stay opted out; already-seeded users stay seeded. + if (choice !== "unset") return; + // No prior choice: seed the default-on subscription and record it. await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); + deps.setExplicitChoice(pubkey, true); } /** @@ -73,7 +79,7 @@ export function startReconciliation( ): () => void { let cancelled = false; - reconcileObserverArchive(deps) + reconcileObserverArchive(pubkey, deps) .then(() => { if (!cancelled) onReady(pubkey); }) diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs new file mode 100644 index 000000000..ed4ab860f --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + backgroundMediaUploadPhaseLabel, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase.ts"; + +test("upload phase labels describe the real work in plain language", () => { + assert.equal(backgroundMediaUploadPhaseLabel("preparing"), "Preparing"); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-video"), + "Processing", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("converting-image"), + "Converting", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-files"), + "Processing", + ); + assert.equal(backgroundMediaUploadPhaseLabel("uploading"), "Uploading"); + assert.equal(backgroundMediaUploadPhaseLabel("finishing"), "Finishing"); +}); + +test("upload phase validation accepts only phases emitted by native code", () => { + assert.equal(isNativeMediaUploadPhase("processing-video"), true); + assert.equal(isNativeMediaUploadPhase("processing-files"), false); + assert.equal(isNativeMediaUploadPhase("transcribing"), false); + assert.equal(isNativeMediaUploadPhase(null), false); +}); + +test("upload phase aggregation favors active transfer and combines mixed work", () => { + assert.equal(resolveBackgroundMediaUploadPhase([]), "preparing"); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "uploading"]), + "uploading", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "preparing"]), + "processing-files", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["finishing", "finishing"]), + "finishing", + ); +}); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts new file mode 100644 index 000000000..0d8a98f2f --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts @@ -0,0 +1,60 @@ +export type BackgroundMediaUploadPhase = + | "preparing" + | "processing-video" + | "converting-image" + | "processing-files" + | "uploading" + | "finishing"; + +export type NativeMediaUploadPhase = Exclude< + BackgroundMediaUploadPhase, + "processing-files" +>; + +const NATIVE_PHASES = new Set([ + "preparing", + "processing-video", + "converting-image", + "uploading", + "finishing", +]); + +export function isNativeMediaUploadPhase( + value: unknown, +): value is NativeMediaUploadPhase { + return ( + typeof value === "string" && + NATIVE_PHASES.has(value as NativeMediaUploadPhase) + ); +} + +export function resolveBackgroundMediaUploadPhase( + phases: BackgroundMediaUploadPhase[], +): BackgroundMediaUploadPhase { + if (phases.length === 0) return "preparing"; + if (phases.includes("uploading")) return "uploading"; + + const activePhases = new Set(phases.filter((phase) => phase !== "finishing")); + if (activePhases.size === 0) return "finishing"; + if (activePhases.size > 1) return "processing-files"; + return activePhases.values().next().value ?? "preparing"; +} + +export function backgroundMediaUploadPhaseLabel( + phase: BackgroundMediaUploadPhase, +): string { + switch (phase) { + case "processing-video": + return "Processing"; + case "converting-image": + return "Converting"; + case "processing-files": + return "Processing"; + case "uploading": + return "Uploading"; + case "finishing": + return "Finishing"; + default: + return "Preparing"; + } +} diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts new file mode 100644 index 000000000..ace711985 --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -0,0 +1,350 @@ +import * as React from "react"; + +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { + type BackgroundMediaUploadPhase, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase"; + +export type QueuedMediaAttachment = { + file: File; + id: number; + previewUrl?: string; + spoilered: boolean; +}; + +type BackgroundUploadTask = { + abortController: AbortController; + canceled: boolean; + filePhases: BackgroundMediaUploadPhase[]; + fileProgress: Array<{ sent: number; total: number }>; + id: number; + isCompleting: boolean; + onCancel?: () => void; +}; + +type BackgroundUploadSnapshot = { + canCancel: boolean; + isUploading: boolean; + phase: BackgroundMediaUploadPhase; + percentage: number; +}; + +type EnqueueBackgroundUploadOptions = { + attachments: QueuedMediaAttachment[]; + onCancel?: () => void; + onComplete: ( + descriptors: BlobDescriptor[], + signal: AbortSignal, + ) => Promise; + onError: (error: unknown) => void; +}; + +type StartBackgroundUploadOptions = Omit< + EnqueueBackgroundUploadOptions, + "attachments" +>; + +export type PreparedBackgroundMediaUpload = { + cancel: () => void; + start: (options: StartBackgroundUploadOptions) => boolean; +}; + +const tasks = new Map(); +const queuedAttachmentsByDraftKey = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let snapshot: BackgroundUploadSnapshot = { + canCancel: false, + isUploading: false, + phase: "preparing", + percentage: 0, +}; +let stopUploadListeners: (() => void)[] = []; +let uploadListenersPromise: Promise | null = null; + +function progressId(taskId: number, fileIndex: number): string { + return `background-media-upload-${taskId}-${fileIndex}`; +} + +function rebuildSnapshot(): void { + const allTasks = [...tasks.values()]; + const allProgress = allTasks.flatMap((task) => task.fileProgress); + const totalBytes = allProgress.reduce( + (total, progress) => total + progress.total, + 0, + ); + const sentBytes = allProgress.reduce( + (total, progress) => total + progress.sent, + 0, + ); + snapshot = { + canCancel: allTasks.some((task) => !task.isCompleting), + isUploading: allTasks.length > 0, + phase: resolveBackgroundMediaUploadPhase( + allTasks.flatMap((task) => task.filePhases), + ), + percentage: + totalBytes === 0 ? 0 : Math.round((sentBytes / totalBytes) * 100), + }; + for (const listener of listeners) listener(); +} + +async function ensureUploadListeners(): Promise { + if (stopUploadListeners.length > 0) return; + if (uploadListenersPromise) { + await uploadListenersPromise; + return; + } + uploadListenersPromise = (async () => { + const disposers: (() => void)[] = []; + try { + const { listen } = await import("@tauri-apps/api/event"); + disposers.push( + await listen<{ + id: string; + sent: number; + total: number; + }>("media-upload-progress", (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || event.payload.total <= 0) return; + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.fileProgress.length) return; + + task.filePhases[fileIndex] = "uploading"; + task.fileProgress[fileIndex] = { + sent: Math.min( + event.payload.total, + Math.max(0, event.payload.sent), + ), + total: event.payload.total, + }; + rebuildSnapshot(); + }), + ); + disposers.push( + await listen<{ id: string; phase: unknown }>( + "media-upload-phase", + (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || !isNativeMediaUploadPhase(event.payload.phase)) { + return; + } + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.filePhases.length) return; + + task.filePhases[fileIndex] = event.payload.phase; + rebuildSnapshot(); + }, + ), + ); + if (tasks.size === 0) { + for (const dispose of disposers) dispose(); + } else { + stopUploadListeners = disposers; + } + } catch { + for (const dispose of disposers) dispose(); + // Browser and E2E runtimes do not emit native upload state. + } finally { + uploadListenersPromise = null; + } + })(); + await uploadListenersPromise; +} + +function finishTask(taskId: number): void { + tasks.delete(taskId); + rebuildSnapshot(); + if (tasks.size === 0) { + for (const dispose of stopUploadListeners) dispose(); + stopUploadListeners = []; + } +} + +function cancelTask( + task: BackgroundUploadTask, + { force = false, notify = true }: { force?: boolean; notify?: boolean } = {}, +): void { + if (task.canceled || (task.isCompleting && !force)) return; + task.canceled = true; + task.abortController.abort(); + if (notify) task.onCancel?.(); + for (let index = 0; index < task.fileProgress.length; index += 1) { + void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); + } + finishTask(task.id); +} + +function yieldForUploadFeedback(): Promise { + if ( + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + document.visibilityState === "hidden" + ) { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + return new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); + }); +} + +export function prepareBackgroundMediaUpload( + attachments: QueuedMediaAttachment[], +): PreparedBackgroundMediaUpload { + if (attachments.length === 0) { + let started = false; + return { + cancel: () => undefined, + start: ({ onComplete, onError }) => { + if (started) return false; + started = true; + void onComplete([], new AbortController().signal).catch(onError); + return true; + }, + }; + } + + const taskId = nextTaskId; + nextTaskId += 1; + const task: BackgroundUploadTask = { + abortController: new AbortController(), + canceled: false, + filePhases: attachments.map(() => "preparing"), + fileProgress: attachments.map((attachment) => ({ + sent: 0, + total: attachment.file.size, + })), + id: taskId, + isCompleting: false, + }; + let started = false; + tasks.set(taskId, task); + rebuildSnapshot(); + + return { + cancel: () => { + cancelTask(task); + }, + start: ({ onCancel, onComplete, onError }) => { + if (started || task.canceled) return false; + started = true; + task.onCancel = onCancel; + + void (async () => { + try { + await ensureUploadListeners(); + // Let React commit and paint the 0% task before file reads or native + // IPC begin, so large attachments never hide the initial feedback. + await yieldForUploadFeedback(); + const descriptors: BlobDescriptor[] = []; + for (let index = 0; index < attachments.length; index += 1) { + if (task.canceled) return; + const attachment = attachments[index]; + const descriptor = await uploadMediaFile( + attachment.file, + progressId(taskId, index), + task.abortController.signal, + ); + if (task.canceled) return; + task.filePhases[index] = "finishing"; + task.fileProgress[index] = { + sent: task.fileProgress[index].total, + total: task.fileProgress[index].total, + }; + rebuildSnapshot(); + descriptors.push(descriptor); + } + + if (!task.canceled) { + task.isCompleting = true; + task.filePhases.fill("finishing"); + rebuildSnapshot(); + await onComplete(descriptors, task.abortController.signal); + } + } catch (error) { + if (!task.canceled) onError(error); + } finally { + finishTask(taskId); + } + })(); + return true; + }, + }; +} + +export function enqueueBackgroundMediaUpload({ + attachments, + onCancel, + onComplete, + onError, +}: EnqueueBackgroundUploadOptions): PreparedBackgroundMediaUpload { + const preparedUpload = prepareBackgroundMediaUpload(attachments); + preparedUpload.start({ onCancel, onComplete, onError }); + return preparedUpload; +} + +export function cancelBackgroundMediaUploads(): void { + for (const task of [...tasks.values()].reverse()) { + if (!task.isCompleting) { + cancelTask(task); + return; + } + } +} + +export function resetBackgroundMediaUploads(): void { + for (const task of [...tasks.values()]) { + cancelTask(task, { force: true, notify: false }); + } + queuedAttachmentsByDraftKey.clear(); +} + +/** + * Retain local files that cannot be serialized with a draft while a deferred + * upload recovers after the user has left its channel. + */ +export function saveQueuedAttachmentsForDraft( + draftKey: string, + attachments: QueuedMediaAttachment[], +): void { + queuedAttachmentsByDraftKey.set(draftKey, attachments); +} + +/** Remove local files retained for a draft without restoring them. */ +export function discardQueuedAttachmentsForDraft(draftKey: string): void { + queuedAttachmentsByDraftKey.delete(draftKey); +} + +/** Return and remove the local files retained for a recovered draft. */ +export function takeQueuedAttachmentsForDraft( + draftKey: string, +): QueuedMediaAttachment[] { + const attachments = queuedAttachmentsByDraftKey.get(draftKey) ?? []; + queuedAttachmentsByDraftKey.delete(draftKey); + return attachments; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundUploadSnapshot { + return snapshot; +} + +export function useBackgroundMediaUpload(): BackgroundUploadSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index f17a53c66..1fe6f66cd 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -106,11 +106,11 @@ test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); }); -test("timelineRowReserveStyle: divider is short fixed height", () => { +test("timelineRowReserveStyle: divider reserves its visual breathing room", () => { const style = timelineRowReserveStyle({ kind: "day-divider", key: "k", headingTimestamp: 0, }); - assert.equal(style.containIntrinsicSize, "auto 32px"); + assert.equal(style.containIntrinsicSize, "auto 56px"); }); diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index acefae95d..196aabbec 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -156,7 +156,7 @@ export function estimateRowHeight( // Dividers are short, fixed-height rows; reserving their true height keeps the // estimate honest without a content scan. -const DIVIDER_HEIGHT = 32; +const DIVIDER_HEIGHT = 56; const SYSTEM_GROUP_HEIGHT = 80; /** diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs index eeed9d543..417685d47 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.test.mjs +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -2,10 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "./systemEventCopy.ts"; +test("an add to the reader uses passive wording", () => { + assert.equal(addedByActionPrefix(true), "were added by"); + assert.equal(addedByActionPrefix(false), "added by"); +}); + test("a set topic is quoted verbatim", () => { assert.equal( describeChannelTextFieldChange("topic", "Release planning"), diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts index bae6abb09..99f731616 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.ts +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -14,6 +14,15 @@ const CLOSE_QUOTE = "”"; export type ChannelTextField = "topic" | "purpose"; +/** + * The reader is the recipient of an add, while every other member is the + * subject of one. Keep that distinction in the caption: "You were added by" + * rather than the ungrammatical "You added by". + */ +export function addedByActionPrefix(isCurrentUser: boolean): string { + return isCurrentUser ? "were added by" : "added by"; +} + /** * Caption for a channel topic or purpose change. * diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 4677fe7b4..2b87b5ebc 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -48,6 +48,15 @@ function memberJoinedEntry({ createdAt, id, target }) { return memberAddedEntry({ actor: target, createdAt, id, target }); } +function memberLeftEntry({ createdAt, id, target }) { + return entry({ + id, + createdAt, + kind: KIND_SYSTEM_MESSAGE, + body: JSON.stringify({ type: "member_left", actor: target }), + }); +} + function kinds(items) { return items.map((item) => item.kind); } @@ -103,12 +112,12 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); -test("buildTimelineItems: member additions by one actor group within five minutes", () => { +test("buildTimelineItems: contiguous member additions by one actor group", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 60 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 300 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_600 }), ]; const { items } = buildTimelineItems(entries, null); @@ -121,7 +130,7 @@ test("buildTimelineItems: member additions by one actor group within five minute assert.equal(group?.key, "c"); }); -test("buildTimelineItems: self-joins group across different members within five minutes", () => { +test("buildTimelineItems: contiguous self-joins group across different members", () => { const start = dayAt(2026, 6, 14); const entries = [ memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -133,7 +142,7 @@ test("buildTimelineItems: self-joins group across different members within five memberJoinedEntry({ id: "c", target: "target-c", - createdAt: start + 300, + createdAt: start + 3_600, }), ]; @@ -149,9 +158,9 @@ test("buildTimelineItems: self-joins group across different members within five test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => { const start = dayAt(2026, 6, 14); const loaded = [ - memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }), - entry({ id: "message", createdAt: start + 600 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_500 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), + entry({ id: "message", createdAt: start + 3_700 }), ]; const prepended = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -164,23 +173,28 @@ test("buildTimelineItems: prepending membership history preserves the loaded suf const prependedKeys = prependedItems.slice(1).map((item) => item.key); assert.deepEqual(loadedKeys, ["c", "message"]); - assert.deepEqual(prependedKeys, ["a", "c", "message"]); + assert.deepEqual(prependedKeys, ["c", "message"]); assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys); }); -test("buildTimelineItems: member-add window is fixed from the newest addition", () => { +test("buildTimelineItems: contiguous member additions extend a group outside one hour", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), - memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 301 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_599 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), ]; const { items } = buildTimelineItems(entries, null); - assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["a", "b", "c"], + ); }); -test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => { +test("buildTimelineItems: incompatible arrivals remain separate", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -216,6 +230,22 @@ test("buildTimelineItems: actor changes and intervening rows break member-add gr ]); }); +test("buildTimelineItems: a member joining then leaving is one lifecycle group", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "joined", target: "member-a", createdAt: start }), + memberLeftEntry({ id: "left", target: "member-a", createdAt: start + 90 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["joined", "left"], + ); +}); + test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => { const entries = [ entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 72b83f0a0..c28387102 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -62,13 +62,10 @@ function entryRenderKey(entry: MainTimelineEntry): string { return entry.message.renderKey ?? entry.message.id; } -const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60; - -type MembershipChangePayload = { - actor: string | null; - mode: "added" | "joined"; - target: string; -}; +type MembershipChangePayload = + | { mode: "self-arrival"; target: string } + | { actor: string; mode: "addition"; target: string } + | { mode: "departure"; target: string }; function parseMembershipChangePayload( entry: MainTimelineEntry, @@ -81,6 +78,10 @@ function parseMembershipChangePayload( actor?: unknown; target?: unknown; }; + if (payload.type === "member_left" && typeof payload.actor === "string") { + const target = payload.actor.trim().toLowerCase(); + return target ? { mode: "departure", target } : null; + } if ( payload.type !== "member_joined" || typeof payload.actor !== "string" || @@ -92,10 +93,9 @@ function parseMembershipChangePayload( const actor = payload.actor.trim().toLowerCase(); const target = payload.target.trim().toLowerCase(); if (!actor || !target) return null; - return actor === target - ? { actor: null, mode: "joined", target } - : { actor, mode: "added", target }; + ? { mode: "self-arrival", target } + : { actor, mode: "addition", target }; } catch { return null; } @@ -105,9 +105,16 @@ function membershipChangesCanGroup( first: MembershipChangePayload, second: MembershipChangePayload, ): boolean { + if (first.mode === "self-arrival") { + return ( + second.mode === "self-arrival" || + (second.mode === "departure" && first.target === second.target) + ); + } return ( - first.mode === second.mode && - (first.mode === "joined" || first.actor === second.actor) + first.mode === "addition" && + second.mode === "addition" && + first.actor === second.actor ); } @@ -116,6 +123,12 @@ function membershipChangesCanGroup( * history cannot repartition the rows that are already loaded. Their key is * likewise the newest entry's key: extending the oldest visible group changes * its contents, but not its identity or the virtual list's existing key suffix. + * + * Compatible membership activities stay together while they are contiguous. + * Self-joins and additions from one administrator each form their own summary; + * a self-join immediately followed by that member leaving becomes a single + * lifecycle summary. Each adjacent event must fall within the one-hour activity + * window, so uninterrupted activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], @@ -134,14 +147,14 @@ function buildMembershipGroups( let start = end; while (start > 0) { const candidate = entries[start - 1]; + const nextEntry = entries[start]; const candidatePayload = parseMembershipChangePayload(candidate); if ( barrierIndexes.has(start) || !candidatePayload || !membershipChangesCanGroup(candidatePayload, newestPayload) || newestEntry.message.createdAt < candidate.message.createdAt || - newestEntry.message.createdAt - candidate.message.createdAt > - MEMBERSHIP_GROUP_WINDOW_SECONDS + nextEntry.message.createdAt - candidate.message.createdAt > 60 * 60 ) { break; } diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index c66c607b9..e4741baa8 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -488,6 +488,20 @@ test("markDraftSent_new_active_draft_after_send_is_independent", () => { assert.equal(getSentDraftEntries().length, 0, "no sent records"); }); +test("markDraftSent_keeps_a_newer_draft_with_the_same_key", () => { + setup("pubkey-sent-race"); + persistDraftEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + // A background upload is still in flight when the user starts the next + // message in this channel. Its completion must not clear this newer entry. + persistDraftEntry("chan-race", "next draft", "chan-race", [IMG_B], []); + + markDraftSentEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + + const draft = loadDraftEntry("chan-race"); + assert.equal(draft?.content, "next draft"); + assert.deepEqual(draft?.pendingImeta, [IMG_B]); +}); + test("getActiveDraftEntries_excludes_cleared_drafts", () => { setup("pubkey-active"); persistDraftEntry("chan-active", "active draft", "chan-active", [], []); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 1e9fee6e5..2a78e8813 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import { discardQueuedAttachmentsForDraft } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; @@ -296,6 +297,11 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined { return readStore().get(draftKey); } +export function deleteDraftEntry(draftKey: string): void { + discardQueuedAttachmentsForDraft(draftKey); + clearDraftEntry(draftKey); +} + export function clearDraftEntry(draftKey: string): void { const map = readStore(); if (map.has(draftKey)) { @@ -495,12 +501,24 @@ export function getSentDraftEntries(): Array<{ */ export function markDraftSentEntry( draftKey: string, - _content: string, - _channelId: string, - _pendingImeta: ImetaMedia[], - _spoileredAttachmentUrls: string[], + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], ): void { - clearDraftEntry(draftKey); + const draft = loadDraftEntry(draftKey); + // A background upload can finish after the user has started the next draft + // in this same channel. Clear only the exact submitted snapshot rather than + // deleting whichever newer entry currently owns the key. + if ( + draft?.content === content && + draft.channelId === channelId && + JSON.stringify(draft.pendingImeta) === JSON.stringify(pendingImeta) && + JSON.stringify(draft.spoileredAttachmentUrls) === + JSON.stringify(spoileredAttachmentUrls) + ) { + clearDraftEntry(draftKey); + } } // ── Reactive hooks ──────────────────────────────────────────────────────────── diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b627633be..b4c3cae44 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,6 +5,7 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; +import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; /** * First 4 hex chars of the sha256 — used as a short display name. @@ -30,6 +31,7 @@ export type UploadingAttachmentPreview = { * (e.g. video transcoding before the HTTP upload starts). */ progress?: number | null; slotIndex?: number; + spoilered?: boolean; type?: string; }; @@ -133,7 +135,22 @@ async function captureVideoPosterFrame( } } -export function useMediaUpload() { +type UseMediaUploadOptions = { + /** Keep newly selected files local until the message is submitted. */ + deferUploadsUntilSend?: boolean; +}; + +export function useMediaUpload({ + deferUploadsUntilSend = false, +}: UseMediaUploadOptions = {}) { + const e2eConfig = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { deferredComposerUploads?: boolean } }; + } + ).__BUZZ_E2E__; + const queueUntilSend = + deferUploadsUntilSend && + (!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true); const [uploadState, setUploadState] = React.useState({ status: "idle", }); @@ -144,6 +161,11 @@ export function useMediaUpload() { >([]); const uploadingPreviewsRef = React.useRef(uploadingPreviews); uploadingPreviewsRef.current = uploadingPreviews; + const [queuedAttachments, setQueuedAttachmentsState] = React.useState< + QueuedMediaAttachment[] + >([]); + const queuedAttachmentsRef = React.useRef(queuedAttachments); + queuedAttachmentsRef.current = queuedAttachments; React.useEffect(() => { let unlisten: (() => void) | null = null; let cancelled = false; @@ -248,6 +270,99 @@ export function useMediaUpload() { * before React flushes the state update. */ const nextSlotRef = React.useRef(0); const nextUploadingPreviewIdRef = React.useRef(0); + const nextQueuedAttachmentIdRef = React.useRef(0); + + const updateQueuedVideoPoster = React.useCallback( + (id: number, posterUrl: string) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, previewUrl: posterUrl } + : attachment, + ), + ); + }, + [], + ); + + const queueFiles = React.useCallback( + (files: File[]) => { + if (files.length === 0) return; + + const attachments = files.map((file) => { + const id = nextQueuedAttachmentIdRef.current; + nextQueuedAttachmentIdRef.current += 1; + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined; + if (file.type.startsWith("video/")) { + void captureVideoPosterFrame(file).then((poster) => { + if (poster) updateQueuedVideoPoster(id, poster.posterUrl); + }); + } + return { file, id, previewUrl, spoilered: false }; + }); + + setQueuedAttachmentsState((current) => [...current, ...attachments]); + }, + [updateQueuedVideoPoster], + ); + + const removeQueuedAttachment = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => { + const removed = current.find((attachment) => attachment.id === id); + if (removed?.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(removed.previewUrl); + } + return current.filter((attachment) => attachment.id !== id); + }); + }, []); + + const clearQueuedAttachments = React.useCallback(() => { + setQueuedAttachmentsState((current) => { + for (const attachment of current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return []; + }); + }, []); + + const restoreQueuedAttachments = React.useCallback( + (attachments: QueuedMediaAttachment[]) => { + clearQueuedAttachments(); + queueFiles(attachments.map((attachment) => attachment.file)); + setQueuedAttachmentsState((current) => + current.map((attachment, index) => ({ + ...attachment, + spoilered: attachments[index]?.spoilered ?? false, + })), + ); + }, + [clearQueuedAttachments, queueFiles], + ); + + const toggleQueuedAttachmentSpoiler = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, spoilered: !attachment.spoilered } + : attachment, + ), + ); + }, []); + + React.useEffect( + () => () => { + for (const attachment of queuedAttachmentsRef.current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + }, + [], + ); const isUploadCanceled = React.useCallback( (previewId?: number) => @@ -367,6 +482,19 @@ export function useMediaUpload() { ); const handlePaperclip = React.useCallback(async () => { + if (queueUntilSend) { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.addEventListener( + "change", + () => queueFiles(Array.from(input.files ?? [])), + { once: true }, + ); + input.click(); + return; + } + // Hold a single pending tick while the native picker is open + uploads // run in Rust. We don't know the file count until the dialog returns, // and uploads are already complete by then, so we just append each @@ -374,7 +502,7 @@ export function useMediaUpload() { const previewId = reserveUploadingPreview(); setUploadingCount((c) => c + 1); try { - const descriptors = await pickAndUploadMedia(); + const descriptors = await pickAndUploadMedia(uploadProgressId(previewId)); if (isUploadCanceled(previewId)) return; finishUpload(previewId); for (const descriptor of descriptors) { @@ -385,7 +513,14 @@ export function useMediaUpload() { if (isUploadCanceled(previewId)) return; onUploadError(err, previewId); } - }, [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview]); + }, [ + queueUntilSend, + finishUpload, + isUploadCanceled, + onUploadError, + queueFiles, + reserveUploadingPreview, + ]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -399,6 +534,11 @@ export function useMediaUpload() { // (active-content + executables) and size caps; everything else uploads. const validFiles = files; + if (queueUntilSend) { + queueFiles(validFiles); + return; + } + setUploadingCount((c) => c + validFiles.length); const baseIndex = reserveSlots(validFiles.length); @@ -425,9 +565,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -497,6 +639,11 @@ export function useMediaUpload() { event.preventDefault(); + if (queueUntilSend) { + queueFiles(mediaFiles); + return; + } + setUploadingCount((c) => c + mediaFiles.length); const baseIndex = reserveSlots(mediaFiles.length); @@ -522,9 +669,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -532,6 +681,10 @@ export function useMediaUpload() { /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */ const uploadFile = React.useCallback( async (file: File) => { + if (queueUntilSend) { + queueFiles([file]); + return; + } const previewId = reserveUploadingPreview(file); setUploadingCount((c) => c + 1); try { @@ -547,7 +700,14 @@ export function useMediaUpload() { onUploadError(err, previewId); } }, - [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], + [ + queueUntilSend, + isUploadCanceled, + onUploaded, + onUploadError, + queueFiles, + reserveUploadingPreview, + ], ); /** @@ -642,10 +802,22 @@ export function useMediaUpload() { ); const isUploading = uploadingCount > 0; + const queuedPreviews = React.useMemo( + () => + queuedAttachments.map((attachment) => ({ + filename: attachment.file.name, + id: attachment.id, + posterUrl: attachment.previewUrl, + spoilered: attachment.spoilered, + type: attachment.file.type, + })), + [queuedAttachments], + ); return React.useMemo( () => ({ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -657,10 +829,16 @@ export function useMediaUpload() { originalUrlByUrl, pendingImeta, pendingImetaRef, + queuedAttachments, + queuedAttachmentsRef, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, setUploadState, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, @@ -669,6 +847,7 @@ export function useMediaUpload() { }), [ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -679,9 +858,14 @@ export function useMediaUpload() { isUploading, originalUrlByUrl, pendingImeta, + queuedAttachments, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs index fbfad321e..daae41a78 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs @@ -221,7 +221,7 @@ test("virtualized rows preserve their heterogeneous height estimates", () => { ); const estimates = items.map(estimateVirtualizedTimelineItemHeight); - assert.equal(estimates[0], 32); + assert.equal(estimates[0], 56); assert.ok(estimates[2] > estimates[1] + 200); assert.equal(estimates.at(-1), 96); }); diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts index e9195128c..1ab68af18 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts @@ -32,7 +32,7 @@ export function estimateVirtualizedTimelineItemHeight( ): number { if (item.kind === "bottom-spacer") return 96; if (item.kind === "leading-content") return 60; - if (item.kind === "day-divider") return 32; + if (item.kind === "day-divider") return 56; return estimateTimelineItemHeight(item.item); } diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index 98300acb3..76d1960b9 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -63,7 +63,7 @@ export function ChannelIntroBlock({

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index bee38e321..8578e4c08 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -61,6 +61,12 @@ type ComposerAttachmentsProps = { attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; + /** Remove a local attachment that has not started uploading yet. */ + onRemoveQueued?: (previewId: number) => void; + /** Toggle spoiler state for a local attachment before it receives a URL. */ + onToggleQueuedSpoiler?: (previewId: number) => void; + /** Local previews that are queued for upload when the message is sent. */ + queuedPreviews?: UploadingAttachmentPreview[]; uploadingCount?: number; uploadingPreviews?: UploadingAttachmentPreview[]; /** Upload annotated bytes as a replacement for the attachment at `url`. */ @@ -511,6 +517,9 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onRemoveQueued, + onToggleQueuedSpoiler, + queuedPreviews = [], onEditSave, onRemove, onRevert, @@ -518,7 +527,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { - if (attachments.length === 0 && !isUploading) return null; + if (attachments.length === 0 && queuedPreviews.length === 0 && !isUploading) + return null; const uploadPlaceholders: UploadingAttachmentPreview[] = uploadingPreviews.length > 0 @@ -609,6 +619,94 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ /> ); })} + {queuedPreviews.map((preview) => { + const isMedia = + preview.type?.startsWith("image/") || + preview.type?.startsWith("video/"); + return ( + + {isMedia ? ( +
+
+ {preview.posterUrl ? ( + {preview.filename + ) : ( +
+ +
+ )} +
+ {preview.spoilered ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ + + {preview.filename ?? "Attachment"} + +
+ )} + {onRemoveQueued ? ( + + + + + Remove attachment + + ) : null} + {isMedia && onToggleQueuedSpoiler ? ( + + + + onToggleQueuedSpoiler(preview.id) + } + pressed={preview.spoilered} + type="button" + > + + + + + {preview.spoilered ? "Remove spoiler" : "Mark as spoiler"} + + + ) : null} +
+ ); + })} {isUploading && uploadPlaceholders.map((preview) => ( void; onCancelReply?: () => void; @@ -39,6 +41,7 @@ export function ComposerReplyEditBanner({
+ ); +} diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx new file mode 100644 index 000000000..288b32f8f --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -0,0 +1,171 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { + type BackgroundMediaUploadPhase, + backgroundMediaUploadPhaseLabel, +} from "@/features/messages/lib/backgroundMediaUploadPhase"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; + +export function ComposerUploadProgressPill({ + canCancel, + isUploading, + onCancel, + phase, + percentage, +}: { + canCancel: boolean; + isUploading: boolean; + onCancel: () => void; + phase: BackgroundMediaUploadPhase; + percentage: number; +}) { + const reducedMotion = useReducedMotion(); + const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const isTransferring = phase === "uploading"; + const phaseTransition = reducedMotion + ? { duration: 0 } + : { duration: 0.18, ease: [0.77, 0, 0.175, 1] as const }; + + return ( + + {isUploading ? ( + +
+ +
+ + + + + {phaseLabel} + + + + + + {isTransferring ? ( + + {percentage}% + + ) : ( + + + )} + + + + + {canCancel ? ( + + ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index 8dff4b7a0..72a396d50 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -1,9 +1,27 @@ -export function DayDivider({ label }: { label: string }) { +import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; + +export function DayDivider({ + label, + sticky = true, + testId = "message-timeline-day-divider", +}: { + label: string; + sticky?: boolean; + testId?: string; +}) { return (

diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b..6f79daa60 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; @@ -10,20 +9,21 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; - import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { + cancelBackgroundMediaUploads, + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, + useBackgroundMediaUpload, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import { @@ -50,14 +50,14 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - +import { submitMessageEdit } from "./submitMessageEdit"; import type { MessageComposerProps } from "./MessageComposer.types"; - function MessageComposerImpl({ audienceContext = null, channelId = null, @@ -71,6 +71,7 @@ function MessageComposerImpl({ onAutoSubmitComplete, editTarget = null, isSending = false, + onDeferredEditPendingChange, onCancelEdit, onCancelReply, onCaptureSendContext, @@ -83,6 +84,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, + showBackgroundUploadProgress = true, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -103,12 +105,10 @@ function MessageComposerImpl({ >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; - const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); }, []); - const drafts = useDrafts(); const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; @@ -124,10 +124,10 @@ function MessageComposerImpl({ : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; - // Snapshot composer state before edit mode so cancel can restore it. const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; + queuedAttachments: ReturnType["queuedAttachments"]; spoileredAttachmentUrls: Set; } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { @@ -141,16 +141,20 @@ function MessageComposerImpl({ typingParentEventId, typingRootEventId, ); - - // We pass a custom setter that both updates React state AND inserts - // markdown into the Tiptap editor when media upload completes. - const internalMedia = useMediaUpload(); + const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; + const [isDeferredEditPending, setDeferredEditPending] = React.useState(false); + const composerDisabled = disabled || isDeferredEditPending; + const isEditSubmissionLocked = + isSending || media.isUploading || isDeferredEditPending; + const canRestoreEditDraftRef = React.useRef(false); + canRestoreEditDraftRef.current = + contentRef.current.trim().length === 0 && + media.pendingImetaRef.current.length === 0 && + media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; - - // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on - // key change, and persist the outgoing draft in the cleanup. The StrictMode - // fix lives inside this hook — see useDraftPersistSnapshot.ts. + const backgroundUpload = useBackgroundMediaUpload(); + // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -160,6 +164,11 @@ function MessageComposerImpl({ restoreMentionRefs: mentions.restoreDraftMentionRefs, livePendingImeta: media.pendingImeta, setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent: (content) => { setComposerContent(content); richText.setContent(content); @@ -179,7 +188,6 @@ function MessageComposerImpl({ channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); }, [effectiveDraftKey]); - const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); @@ -198,16 +206,13 @@ function MessageComposerImpl({ editTargetRef.current = editTarget; extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; ownerPubkeyRef.current = ownerPubkey; - const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || emojiAutocomplete.isEmojiAutocompleteOpen; - const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler // delegates through this ref to break the hook ordering cycle (the editor // needs `onEditLink`, but the link editor needs the editor's `richText`). @@ -218,7 +223,6 @@ function MessageComposerImpl({ ((info: LinkSelectionInfo | null) => void) | null >(null); const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null); - const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { const scrollElement = composerScrollRef.current; @@ -226,17 +230,15 @@ function MessageComposerImpl({ scrollElement.scrollTop = scrollElement.scrollHeight; }); }, []); - const computedPlaceholder = editTarget ? "Edit your message" : (placeholder ?? (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); - const richText = useRichTextEditor({ placeholder: computedPlaceholder, - editable: !disabled, + editable: !composerDisabled, mentionNames: mentions.knownNames, agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, @@ -255,19 +257,15 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); - mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); - persistentMentionHydrationRef.current?.reconcile(text); - if (text.trim().length > 0) { notifyTyping(); } }, }); - const linkEditor = useLinkEditor(richText); syncContentRefFromEditorRef.current = () => { const markdown = richText.getMarkdown(); @@ -278,7 +276,6 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentMentionHydration = usePersistentAgentMentionHydration({ audienceScope, hydrationKey: effectiveDraftKey, @@ -292,7 +289,6 @@ function MessageComposerImpl({ persistentMentionHydration, ); persistentMentionHydrationRef.current = persistentMentionHydration; - const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -308,6 +304,11 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + hasUnsavedMedia: () => + media.pendingImetaRef.current.length > 0 || + media.queuedAttachmentsRef.current.length > 0, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -322,16 +323,18 @@ function MessageComposerImpl({ : undefined, resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, }); - + React.useEffect(() => { + onDeferredEditPendingChange?.(isDeferredEditPending); + return () => onDeferredEditPendingChange?.(false); + }, [isDeferredEditPending, onDeferredEditPendingChange]); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { - // Snapshot the current draft (text + attachments) so the user's - // in-flight work survives the edit-mode hijack and is restored on - // edit-cancel/exit. + // Preserve the user's in-flight draft while editing another message. preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), pendingImeta: [...media.pendingImetaRef.current], + queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; // Strip the trailing `![image|video](url)` lines that correspond to @@ -348,6 +351,7 @@ function MessageComposerImpl({ // attachments so they show up in `ComposerAttachments` and the user // can remove existing ones / add new ones before saving. media.setPendingImeta(editableImeta); + media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); @@ -363,6 +367,7 @@ function MessageComposerImpl({ const { content: restoredContent, pendingImeta: restoredImeta, + queuedAttachments: restoredQueuedAttachments, spoileredAttachmentUrls: restoredSpoileredAttachmentUrls, } = preEditSnapshotRef.current; preEditSnapshotRef.current = null; @@ -371,21 +376,19 @@ function MessageComposerImpl({ ? richText.setContent(restoredContent) : richText.clearContent(); media.setPendingImeta(restoredImeta); + media.restoreQueuedAttachments(restoredQueuedAttachments); setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); - // ── Focus on reply ────────────────────────────────────────────────── // Use focusPreserve so that re-renders (e.g. new messages arriving in // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { - if (!replyTarget || disabled) return; + if (!replyTarget || composerDisabled) return; richText.focusPreserve(); - }, [disabled, replyTarget, richText.focusPreserve]); - + }, [composerDisabled, replyTarget, richText.focusPreserve]); // ── Autofocus on mount / channel switch ───────────────────────────── - useComposerAutofocus(richText.focus, effectiveDraftKey, disabled); - + useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). @@ -400,7 +403,6 @@ function MessageComposerImpl({ }, [richText.replacePlainTextRange], ); - const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -412,7 +414,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -424,7 +425,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyEmojiInsert = React.useCallback( (suggestion: EmojiSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -436,7 +436,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -473,12 +472,10 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); - // Check if there's already an @-query in progress const beforeCursor = text.slice(0, cursor); if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { @@ -486,14 +483,12 @@ function MessageComposerImpl({ richText.focus(); return; } - // Insert @ at cursor const previousChar = text.slice(0, cursor).slice(-1); const prefix = cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; richText.editor.chain().focus().insertContent(prefix).run(); setIsEmojiPickerOpen(false); - // Trigger mention detection after inserting @ const { text: updatedText, cursor: updatedCursor } = richText.getPlainTextAndCursor(); @@ -504,90 +499,66 @@ function MessageComposerImpl({ richText.focus, mentions.updateMentionQuery, ]); - // ── Submit message ────────────────────────────────────────────────── const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); - // Edit mode if (editTargetRef.current && onEditSaveRef.current) { - if (isSendingRef.current || isUploadingRef.current) return; - const currentPendingImeta = media.pendingImetaRef.current; + if (isEditSubmissionLocked) return; // No empty-edit guard here: clearing an edit to empty (no text, no // attachments) flows through to onEditSave as empty content, which // deletes the message instead of publishing it (see handleEditSave). - - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, + await submitMessageEdit({ + content: trimmed, + editTargetId: editTargetRef.current.id, + customEmoji, + originalContent: editTargetRef.current.body, + ownerPubkey: ownerPubkeyRef.current, + getMentionRefs: mentions.getDraftMentionRefs, + pendingImeta: media.pendingImetaRef.current, + queuedAttachments: media.queuedAttachmentsRef.current, spoileredAttachmentUrls, - ); - - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); - - const savedContent = trimmed; - const savedImeta = [...currentPendingImeta]; - const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); - setComposerContent(""); - richText.clearContent(); - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - - try { - await onEditSaveRef.current( - finalContent, - outgoingTags, - addedMentionPubkeys, - ); - } catch { - setComposerContent(savedContent); - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); - } + extractMentionPubkeys: extractMentionPubkeysRef.current, + save: onEditSaveRef.current, + clearComposer: () => { + setComposerContent(""); + richText.clearContent(); + media.setPendingImeta([]); + media.clearQueuedAttachments(); + setSpoileredAttachmentUrls(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, + restoreComposer: (draft) => { + setComposerContent(draft.content); + richText.setContent(draft.content); + media.setPendingImeta(draft.pendingImeta); + media.restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls); + }, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + shouldRestoreComposer: () => canRestoreEditDraftRef.current, + setDeferredUploadPending: setDeferredEditPending, + setUploadError: (message) => + media.setUploadState({ status: "error", message }), + }); return; } - // Normal send const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; + const currentQueuedAttachments = media.queuedAttachmentsRef.current; + const hasMedia = + currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0; if ( (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || - isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; } - const capturedThreadContext = onCaptureSendContext?.() ?? null; if ( capturedThreadContext !== null && @@ -595,7 +566,6 @@ function MessageComposerImpl({ ) { return; } - onPreparingMentionSendChange?.(true); persistentMentionHydration.beginSubmit(); try { @@ -603,10 +573,12 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, + queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, ), + recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, audienceGeneration: persistentAudience.generation, @@ -622,8 +594,12 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + media.clearQueuedAttachments, media.pendingImetaRef, + media.queuedAttachmentsRef, + media.restoreQueuedAttachments, media.setPendingImeta, + media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, mentions.clearMentions, @@ -638,9 +614,12 @@ function MessageComposerImpl({ persistentMentionHydration, persistentAudience.generation, persistentAudience.revision, + isEditSubmissionLocked, + effectiveDraftKey, + mentions.getDraftMentionRefs, + mentions.restoreDraftMentionRefs, ]); submitMessageRef.current = submitMessage; - // ── Auto-submit on draft send ──────────────────────────────────────────── // When `autoSubmitDraftKey` is set (the user clicked "Send message" in the // Drafts panel and confirmed), fire `submitMessage` once after mount so the @@ -654,7 +633,6 @@ function MessageComposerImpl({ // runs, preventing re-fire on re-render or back-navigation. const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete); onAutoSubmitCompleteRef.current = onAutoSubmitComplete; - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only React.useEffect(() => { if ( @@ -677,7 +655,6 @@ function MessageComposerImpl({ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount-only - const handleSubmit = React.useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -685,7 +662,6 @@ function MessageComposerImpl({ }, [submitMessage], ); - // ── Keyboard handling ─────────────────────────────────────────────── // Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively. // Plain Enter → submit is now handled inside the Tiptap `submitOnEnter` @@ -701,7 +677,6 @@ function MessageComposerImpl({ } return; } - const channelResult = channelLinks.handleChannelKeyDown(event); if (channelResult.handled) { if (channelResult.suggestion) { @@ -709,7 +684,6 @@ function MessageComposerImpl({ } return; } - const { handled, suggestion } = mentions.handleMentionKeyDown(event); if (handled) { if (suggestion) { @@ -717,7 +691,6 @@ function MessageComposerImpl({ } return; } - if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { event.preventDefault(); if (!linkEditor.focusCardFirstControl()) { @@ -727,7 +700,12 @@ function MessageComposerImpl({ } // Escape in edit mode - if (event.key === "Escape" && editTargetRef.current && onCancelEdit) { + if ( + event.key === "Escape" && + !isDeferredEditPending && + editTargetRef.current && + onCancelEdit + ) { event.preventDefault(); onCancelEdit(); return; @@ -742,6 +720,7 @@ function MessageComposerImpl({ applyMentionInsert, linkEditor.isCardOpen, linkEditor.focusCardFirstControl, + isDeferredEditPending, onCancelEdit, ], ); @@ -824,22 +803,24 @@ function MessageComposerImpl({ // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( () => - disabled || - media.isUploading || + composerDisabled || + (editTarget !== null && media.isUploading) || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0), [ - disabled, + composerDisabled, + editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => { - // No-op for Tiptap — selection is managed by ProseMirror. - }, []); + const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -893,10 +874,20 @@ function MessageComposerImpl({

+ {showBackgroundUploadProgress ? ( + + ) : null}
{ + if (isDeferredEditPending) { + e.preventDefault(); + return; + } void media.handleDrop(e); } : undefined @@ -956,12 +951,17 @@ function MessageComposerImpl({
) : null} - {(media.pendingImeta.length > 0 || media.isUploading) && ( + {(media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading) && (
void; onCancelEdit?: () => void; onCancelReply?: () => void; /** @@ -66,6 +67,8 @@ export type MessageComposerProps = { content: string, mediaTags?: string[][], mentionPubkeys?: string[], + /** Target captured when the edit was submitted; avoids a later ref swap. */ + eventId?: string, ) => Promise; /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { @@ -92,6 +95,8 @@ export type MessageComposerProps = { id: string; } | null; showTopBorder?: boolean; + /** Render the app-wide upload queue above this composer dock. */ + showBackgroundUploadProgress?: boolean; toolbarExtraActions?: ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index f6bfff384..46b5d91d5 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -237,10 +237,15 @@ import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; // Real storage functions — the test uses them, not a replica. import { clearAllDrafts, + deleteDraftEntry, initDraftStore, loadDraftEntry, persistDraftEntry, } from "../lib/useDrafts.ts"; +import { + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, +} from "../lib/backgroundMediaUploadStore.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -579,3 +584,81 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); + +test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { + setupStore("pubkey-switch-files"); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 7, + spoilered: false, + }; + let draftKey = "chan-a"; + let editorContent = ""; + let queuedAttachments = []; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + return null; + } + + saveQueuedAttachmentsForDraft("chan-a", [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + + draftKey = "chan-b"; + await handle.rerender(); + assert.deepEqual(queuedAttachments, [], "B must not inherit A's local files"); + + draftKey = "chan-a"; + await handle.rerender(); + assert.equal( + queuedAttachments[0]?.file.name, + "report.pdf", + "A's attachment-only draft survives a full A → B → A switch", + ); + + await handle.unmount(); +}); + +test("discarding_a_draft_drops_its_retained_local_files", () => { + const retainedFile = { + file: new File(["private"], "private.pdf", { + type: "application/pdf", + }), + id: 8, + spoilered: false, + }; + + saveQueuedAttachmentsForDraft("chan-deleted", [retainedFile]); + deleteDraftEntry("chan-deleted"); + + assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); +}); diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index cbcb873f5..d4bec8db6 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -17,7 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const REACTION_PILL_BASE_CLASSES = "inline-flex h-7 items-center rounded-full border text-xs font-medium leading-none transition-colors"; -const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5 -translate-y-[0.5px]"; +const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5"; const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs"; const REACTION_COUNT_CLASSES = "text-muted-foreground"; const REACTION_NATIVE_COUNT_CLASSES = diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 40e235d2d..6d9c49b75 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -689,8 +689,8 @@ const MessageTimelineBase = React.forwardRef< {showUnreadPill ? (
@@ -854,8 +854,9 @@ const MessageTimelineBase = React.forwardRef< {!isAtBottom ? (
diff --git a/desktop/src/features/messages/ui/SystemMessageAvatars.tsx b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx new file mode 100644 index 000000000..e3792259b --- /dev/null +++ b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx @@ -0,0 +1,196 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MAX_MEMBERSHIP_AVATARS = 5; + +function resolveAvatarUrl( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string | null { + if (!pubkey || !profiles) return null; + return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null; +} + +function isKnownAgentPubkey( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, + personaLookup?: Map, + agentPubkeys?: ReadonlySet, +) { + if (!pubkey) return false; + const normalizedPubkey = normalizePubkey(pubkey); + return ( + agentPubkeys?.has(normalizedPubkey) === true || + profiles?.[normalizedPubkey]?.isAgent === true || + personaLookup?.has(normalizedPubkey) === true + ); +} + +export function SystemMessageAvatar({ + actorPubkey, + agentPubkeys, + currentPubkey, + personaLookup, + profiles, + targetPubkey, +}: { + actorPubkey: string | undefined; + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; + targetPubkey: string | undefined; +}) { + const hasActorAndTarget = + actorPubkey && targetPubkey && actorPubkey !== targetPubkey; + const actorLabel = actorPubkey + ? resolveUserLabel({ + pubkey: actorPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : "Someone"; + const singlePubkey = actorPubkey ?? targetPubkey; + + if (!hasActorAndTarget) { + const isSingleAgent = isKnownAgentPubkey( + singlePubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const avatar = ( + + ); + if (singlePubkey) { + return ( + + + + ); + } + return avatar; + } + + const isActorAgent = isKnownAgentPubkey( + actorPubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const targetLabel = resolveUserLabel({ + pubkey: targetPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const dualAvatar = ( +
+ + +
+ ); + return ( + + + + ); +} + +export function MembershipAvatarStack({ + currentPubkey, + profiles, + pubkeys, +}: { + currentPubkey: string | undefined; + profiles: UserProfileLookup | undefined; + pubkeys: readonly string[]; +}) { + const visiblePubkeys = pubkeys.slice(0, MAX_MEMBERSHIP_AVATARS); + if (visiblePubkeys.length === 0) return null; + return ( +
+ {visiblePubkeys.map((pubkey, index) => { + const label = resolveUserLabel({ + pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + return ( +
0 && "-ml-1")} + data-testid="system-message-avatar" + key={pubkey} + style={{ zIndex: index + 1 }} + > + + + +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index c4637d282..1da16e437 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -29,12 +29,17 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { + MembershipAvatarStack, + SystemMessageAvatar, +} from "./SystemMessageAvatars"; const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0"; const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4"; @@ -77,34 +82,29 @@ function buildGroupedMembershipPayload( if (messages.length < 2) return null; const payloads = messages.map(parseSystemMessagePayload); - const firstPayload = payloads[0]; - const actor = firstPayload?.actor - ? normalizePubkey(firstPayload.actor) - : null; - const firstTarget = firstPayload?.target - ? normalizePubkey(firstPayload.target) - : null; - if (!actor || !firstTarget) return null; - const isSelfJoinGroup = actor === firstTarget; + const joinedThenLeft = buildJoinedThenLeftPayload(payloads); + if (joinedThenLeft) return joinedThenLeft; - const targets: string[] = []; - for (const payload of payloads) { + const arrivals = payloads.map((payload) => { const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; const payloadTarget = payload?.target ? normalizePubkey(payload.target) : null; - if ( - payload?.type !== "member_joined" || - !payloadActor || - !payloadTarget || - (isSelfJoinGroup - ? payloadActor !== payloadTarget - : payloadActor !== actor || payloadActor === payloadTarget) - ) { + if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) { return null; } - targets.push(payloadTarget); - } + return { actor: payloadActor, target: payloadTarget }; + }); + if (arrivals.some((arrival) => !arrival)) return null; + + const membershipArrivals = arrivals as { + actor: string; + target: string; + }[]; + const targets = membershipArrivals.map(({ target }) => target); + const isSelfJoinGroup = membershipArrivals.every( + ({ actor, target }) => actor === target, + ); if (isSelfJoinGroup) { return { @@ -114,6 +114,15 @@ function buildGroupedMembershipPayload( }; } + const actor = membershipArrivals[0].actor; + const isSameAdderGroup = membershipArrivals.every( + ({ actor: candidateActor, target }) => + candidateActor === actor && candidateActor !== target, + ); + if (!isSameAdderGroup) { + return null; + } + return { type: "members_added", actor, @@ -122,6 +131,32 @@ function buildGroupedMembershipPayload( }; } +function buildJoinedThenLeftPayload( + payloads: readonly (SystemMessagePayload | null)[], +): SystemMessagePayload | null { + if (payloads.length !== 2) return null; + + const [arrival, departure] = payloads; + const arrivalTarget = arrival?.target + ? normalizePubkey(arrival.target) + : null; + const departureActor = departure?.actor + ? normalizePubkey(departure.actor) + : null; + if ( + arrival?.type !== "member_joined" || + departure?.type !== "member_left" || + !arrival.actor || + !arrivalTarget || + normalizePubkey(arrival.actor) !== arrivalTarget || + arrivalTarget !== departureActor + ) { + return null; + } + + return { type: "member_joined_then_left", target: arrival.target }; +} + function aggregateGroupedReactions( messages: readonly TimelineMessage[], ): TimelineReaction[] { @@ -277,116 +312,17 @@ function ProfileName({ ); } -function SystemMessageAvatar({ - actorPubkey, - agentPubkeys, - currentPubkey, - personaLookup, - profiles, - targetPubkey, -}: { - actorPubkey: string | undefined; - agentPubkeys?: ReadonlySet; - currentPubkey: string | undefined; - personaLookup?: Map; - profiles: UserProfileLookup | undefined; - targetPubkey: string | undefined; -}) { - const hasActorAndTarget = - actorPubkey && targetPubkey && actorPubkey !== targetPubkey; - const actorLabel = actorPubkey - ? resolveUserLabel({ - pubkey: actorPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }) - : "Someone"; +function membershipActivityPubkeys(payload: SystemMessagePayload): string[] { + const pubkeys = + payload.type === "members_added" || payload.type === "members_joined" + ? (payload.targets ?? []) + : payload.type === "member_removed" + ? [payload.target ?? payload.actor] + : [payload.target ?? payload.actor]; - const singlePubkey = actorPubkey ?? targetPubkey; - - if (!hasActorAndTarget) { - const isSingleAgent = isKnownAgentPubkey( - singlePubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const avatar = ( - - ); - - if (singlePubkey) { - return ( - - - - ); - } - - return avatar; - } - - const isActorAgent = isKnownAgentPubkey( - actorPubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const targetLabel = resolveUserLabel({ - pubkey: targetPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - - const dualAvatar = ( -
- - -
- ); - - return ( - - - - ); + return [ + ...new Set(pubkeys.filter((pubkey): pubkey is string => Boolean(pubkey))), + ]; } function MembershipPersonName({ @@ -508,6 +444,10 @@ function describeSystemEvent( personaLookup?: Map, agentPubkeys?: ReadonlySet, ): SystemMessageDescription | null { + const isTargetCurrentUser = + currentPubkey !== undefined && + payload.target !== undefined && + normalizePubkey(payload.target) === normalizePubkey(currentPubkey); const isTargetAgent = isKnownAgentPubkey( payload.target, profiles, @@ -554,7 +494,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -590,6 +530,12 @@ function describeSystemEvent( ), }; + case "member_joined_then_left": + if (!payload.target) return null; + return { + title: membershipTitle, + action: "joined, then left the channel", + }; case "member_joined": { if (!payload.actor || !payload.target) return null; if (normalizePubkey(payload.actor) === normalizePubkey(payload.target)) { @@ -602,7 +548,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -766,6 +712,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ payload.type === "member_joined" || payload.type === "members_added" || payload.type === "members_joined"; + const isMembershipActivity = + isMembershipArrival || + payload.type === "member_joined_then_left" || + payload.type === "member_left" || + payload.type === "member_removed"; + const membershipPubkeys = isMembershipActivity + ? membershipActivityPubkeys(payload) + : []; const displayedIdentityPubkey = isMembershipArrival ? payload.target : payload.actor; @@ -799,140 +753,172 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser, ); + const reactionsContent = ( +
+ { + setBadgeBurstEmoji((current) => (current === emoji ? null : current)); + }} + onSelect={(emoji) => { + void handleReactionSelect(emoji); + }} + /> + {reactionErrorMessage ? ( +

+ {reactionErrorMessage} +

+ ) : null} +
+ ); + + const reactionPicker = canToggleReactions ? ( +
+
+ + + + + + + + React + + + {reactionErrorMessage ? ( +
+

+ {reactionErrorMessage} +

+
+ ) : null} + { + if ( + !reactionPending && + wouldAddReaction(value) && + isPositiveEmojiParticle(value) + ) { + setBadgeBurstEmoji(value); + } + void handleReactionSelect(value) + .then(() => { + recordQuickReactionEmoji(value); + }) + .catch(() => {}) + .finally(() => { + setIsReactionPickerOpen(false); + }); + }} + /> +
+
+
+
+ ) : null; + return (
-
- -
- - - {description.title} - - {displayedIdentityIsAgent ? ( - +
+
+ - ) : null} - - -

- {description.action} -

-
- { - setBadgeBurstEmoji((current) => - current === emoji ? null : current, - ); - }} - onSelect={(emoji) => { - void handleReactionSelect(emoji); - }} - /> - {reactionErrorMessage ? ( -

- {reactionErrorMessage} +

+ {description.title} {description.action}

- ) : null} +
+
+
{reactionsContent}
+
+ ) : ( +
+ +
+ + + {description.title} + + {displayedIdentityIsAgent ? ( + + ) : null} + + +

+ {description.action} +

+ {reactionsContent}
-
- {canToggleReactions ? ( -
-
- - - - - - - - React - - - {reactionErrorMessage ? ( -
-

- {reactionErrorMessage} -

-
- ) : null} - { - if ( - !reactionPending && - wouldAddReaction(value) && - isPositiveEmojiParticle(value) - ) { - setBadgeBurstEmoji(value); - } - void handleReactionSelect(value) - .then(() => { - recordQuickReactionEmoji(value); - }) - .catch(() => {}) - .finally(() => { - setIsReactionPickerOpen(false); - }); - }} - /> -
-
-
-
- ) : null} -
+ )} +
+ {reactionPicker}
); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 9c5b143db..bf2da03f4 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -3,7 +3,6 @@ import { VList } from "virtua"; import type { VListHandle } from "virtua"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; -import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; import { buildTimelineDayGroups, buildTimelineItems, @@ -18,21 +17,18 @@ import { type VirtualizedTimelineItem, virtualizedItemKey, } from "@/features/messages/lib/virtualizedTimelineItems"; -import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; -import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; -import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; import { DayDivider } from "./DayDivider"; -import { MessageRow } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; -import { SystemMessageRow } from "./SystemMessageRow"; +import { MessageRowItem, SystemRow } from "./TimelineMessageRow"; +import { TimelineRowShell } from "./TimelineRowShell"; import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; @@ -345,7 +341,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ "relative flex flex-col", !hideDayDividers && group.headingTimestamp !== null && - "before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']", + "before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']", )} data-day-label={ group.headingTimestamp === null @@ -369,10 +365,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); -function timelineItemMessageId(item: TimelineNonDayItem): string | null { +function timelineItemMessageIds(item: TimelineNonDayItem): string[] { + if (item.kind === "system-group") { + return item.entries.map((entry) => entry.message.id); + } return item.kind === "message" || item.kind === "system" - ? item.entry.message.id - : null; + ? [item.entry.message.id] + : []; } type VirtualizedTimelineRowsProps = { @@ -437,6 +436,8 @@ function VirtualizedTimelineRows({ typeof window === "undefined" ? 1_000 : window.innerHeight, ); const hasInitialPositionedRef = React.useRef(false); + const pinnedDayLabelRef = React.useRef(null); + const pinnedDayTranslateYRef = React.useRef(0); const estimateCallCountRef = React.useRef(0); const estimateItemSize = React.useCallback( (item: VirtualizedTimelineItem) => { @@ -462,6 +463,17 @@ function VirtualizedTimelineRows({ [dayGroups, hideDayDividers, historyExhausted, leadingContent], ); const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); + const dayDividerItems = React.useMemo( + () => + items.flatMap((item, index) => + item.kind === "day-divider" ? [{ index, item }] : [], + ), + [items], + ); + const [pinnedDay, setPinnedDay] = React.useState<{ + label: string | null; + incomingLabel: string | null; + }>({ label: null, incomingLabel: null }); itemsLengthRef.current = items.length; const previousKeysRef = React.useRef([]); const [prependShiftEpoch, clearPrependShift] = React.useReducer( @@ -475,6 +487,128 @@ function VirtualizedTimelineRows({ cancelBottomSettle, ); + const updatePinnedDayLabel = React.useCallback( + (offset: number) => { + const list = listRef.current; + const scroller = hostRef.current?.firstElementChild; + const pinnedLabel = pinnedDayLabelRef.current; + if (!list || !(scroller instanceof HTMLDivElement) || !pinnedLabel) { + return; + } + + const pinnedTop = + pinnedLabel.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + pinnedDayTranslateYRef.current; + const [pinnedPill, incomingPinnedPill] = + pinnedLabel.querySelectorAll("p"); + const pinnedPillHeight = pinnedPill?.offsetHeight ?? 0; + if (pinnedPillHeight === 0) return; + const renderedDividerPillTop = ( + divider: (typeof dayDividerItems)[number], + ) => { + const label = formatDayHeading(divider.item.headingTimestamp); + const source = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"]', + ), + ].find((element) => element.dataset.dayLabel === label); + const pill = source?.querySelector("p"); + return pill + ? pill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + : null; + }; + const sourcePills = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"] p', + ), + ]; + // Source dividers are normally visible in the feed. Only hide the one + // that physically overlaps the floating chip at the handoff point. + for (const pill of sourcePills) { + pill.style.removeProperty("visibility"); + } + + let activeDividerIndex = -1; + for (const [index, divider] of dayDividerItems.entries()) { + if (list.getItemOffset(divider.index) > offset + pinnedTop) break; + activeDividerIndex = index; + } + const candidateDivider = dayDividerItems[activeDividerIndex]; + // Retain the previous date while the next in-flow divider is still + // above the sticky slot. This avoids changing the label before the + // moving chip reaches its handoff point. + if ( + activeDividerIndex > 0 && + candidateDivider && + (renderedDividerPillTop(candidateDivider) ?? -Infinity) > pinnedTop + ) { + activeDividerIndex -= 1; + } + const activeDivider = dayDividerItems[activeDividerIndex]; + const nextDivider = dayDividerItems[activeDividerIndex + 1]; + const nextDividerTop = nextDivider + ? (renderedDividerPillTop(nextDivider) ?? + list.getItemOffset(nextDivider.index) - offset) + : null; + const nextTranslateY = + nextDividerTop === null + ? 0 + : Math.max( + -pinnedPillHeight, + Math.min(0, nextDividerTop - pinnedTop - pinnedPillHeight), + ); + if (pinnedDayTranslateYRef.current !== nextTranslateY) { + pinnedDayTranslateYRef.current = nextTranslateY; + pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`; + } + const nextLabel = activeDivider + ? formatDayHeading(activeDivider.item.headingTimestamp) + : null; + const incomingLabel = + nextDivider && nextTranslateY < 0 + ? formatDayHeading(nextDivider.item.headingTimestamp) + : null; + const activeSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === nextLabel, + ); + if (activeSourcePill) { + const sourceTop = + activeSourcePill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + const overlayTop = pinnedTop; + const sourceBottom = sourceTop + activeSourcePill.offsetHeight; + const overlayBottom = overlayTop + pinnedPillHeight; + if (sourceBottom > overlayTop && sourceTop < overlayBottom) { + activeSourcePill.style.visibility = "hidden"; + } + } + const incomingSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === incomingLabel, + ); + if (incomingSourcePill) { + incomingSourcePill.style.visibility = "hidden"; + } + if (pinnedPill) { + pinnedPill.textContent = nextLabel ?? ""; + pinnedPill.style.visibility = nextLabel ? "visible" : "hidden"; + } + if (incomingPinnedPill) { + incomingPinnedPill.textContent = incomingLabel ?? ""; + incomingPinnedPill.style.visibility = incomingLabel + ? "visible" + : "hidden"; + } + setPinnedDay((current) => + current.label === nextLabel && current.incomingLabel === incomingLabel + ? current + : { label: nextLabel, incomingLabel }, + ); + }, + [dayDividerItems], + ); + React.useEffect( () => () => { cancelBottomSettle(); @@ -502,8 +636,9 @@ function VirtualizedTimelineRows({ const byId = new Map(); items.forEach((item, index) => { if (item.kind !== "timeline-item") return; - const messageId = timelineItemMessageId(item.item); - if (messageId) byId.set(messageId, index); + for (const messageId of timelineItemMessageIds(item.item)) { + byId.set(messageId, index); + } }); return byId; }, [items]); @@ -523,6 +658,10 @@ function VirtualizedTimelineRows({ return () => onVirtualizerScrollerChange?.(null); }, [onVirtualizerScrollerChange]); + React.useLayoutEffect(() => { + updatePinnedDayLabel(listRef.current?.scrollOffset ?? 0); + }, [updatePinnedDayLabel]); + React.useLayoutEffect(() => { if (!onVirtualizerApiChange) return; const api: TimelineVirtualizerApi = { @@ -578,6 +717,7 @@ function VirtualizedTimelineRows({ // channel above its newest message. The settle hook's wheel, pointer, // touch, and key listeners are the authoritative user-interaction gate. onAtBottomStateChange?.(distanceFromBottom <= 32); + updatePinnedDayLabel(offset); if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. armUpwardMomentum(onStartReached?.() ?? false); @@ -588,11 +728,12 @@ function VirtualizedTimelineRows({ onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, + updatePinnedDayLabel, ], ); return ( -
+
- -
+
); } @@ -653,236 +786,34 @@ function VirtualizedTimelineRows({ }}
-
- ); -} - -function TimelineRowShell({ - children, - item, - useContentVisibility = true, -}: { - children: React.ReactNode; - item: TimelineNonDayItem; - useContentVisibility?: boolean; -}) { - return ( -
- {children} -
- ); -} - -function SystemRow({ - currentPubkey, - entries, - entry, - footer, - onToggleReaction, - profiles, - ownerProfiles, -}: { - currentPubkey?: string; - entries?: MainTimelineEntry[]; - entry?: MainTimelineEntry; - footer: React.ReactNode; - onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; - profiles?: UserProfileLookup; - ownerProfiles?: UserProfileLookup; -}) { - const systemEntries = entries ?? (entry ? [entry] : []); - const firstEntry = systemEntries[0]; - const groupedMessages = React.useMemo( - () => entries?.map((systemEntry) => systemEntry.message), - [entries], - ); - if (!firstEntry) return null; - - return ( -
- - {footer} -
- ); -} - -type MessageRowItemProps = Pick< - TimelineMessageListProps, - | "channelId" - | "currentPubkey" - | "followThreadById" - | "highlightedMessageId" - | "huddleMemberPubkeys" - | "huddleMemberPubkeysPending" - | "hideAgentAccessBadges" - | "isFollowingThreadById" - | "onDelete" - | "onEdit" - | "onMarkUnread" - | "onMarkRead" - | "onReply" - | "onOpenThread" - | "onToggleReaction" - | "profiles" - | "searchActiveMessageId" - | "searchMatchingMessageIds" - | "searchQuery" - | "threadUnreadCounts" - | "unfollowThreadById" -> & { - entry: MainTimelineEntry; - footer: React.ReactNode; - isContinuation?: boolean; - isFollowedByContinuation?: boolean; - isUnread?: boolean; - playEntrance?: boolean; - onEntranceComplete?: (messageId: string) => void; - videoReviewContext: ReturnType; -}; - -function MessageRowItem({ - channelId, - currentPubkey, - entry, - followThreadById, - footer, - highlightedMessageId, - huddleMemberPubkeys, - huddleMemberPubkeysPending, - hideAgentAccessBadges, - isContinuation = false, - isFollowedByContinuation = false, - isFollowingThreadById, - isUnread, - playEntrance = false, - onEntranceComplete, - onDelete, - onEdit, - onMarkUnread, - onMarkRead, - onReply, - onOpenThread, - onToggleReaction, - profiles, - searchActiveMessageId, - searchMatchingMessageIds, - searchQuery, - threadUnreadCounts, - unfollowThreadById, - videoReviewContext, -}: MessageRowItemProps) { - const { message, summary } = entry; - const canManage = canManageMessageForCurrentUser( - message, - currentPubkey, - profiles, - ); - const canDelete = canManage && onDelete ? onDelete : undefined; - const canEdit = canManage && onEdit ? onEdit : undefined; - - if (summary && onOpenThread) { - const isHighlighted = message.id === highlightedMessageId; - return (
- followThreadById(message.id) : undefined - } - onMarkRead={onMarkRead} - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - showDepthGuides={false} - videoReviewContext={videoReviewContext} - /> - - {footer} +
+ +
+
+ + +
- ); - } - - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - - return ( -
- - {footer}
); } diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx new file mode 100644 index 000000000..283760fb0 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -0,0 +1,228 @@ +import * as React from "react"; + +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; +import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { MessageRow } from "./MessageRow"; +import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { SystemMessageRow } from "./SystemMessageRow"; + +type ToggleReaction = ( + message: TimelineMessage, + emoji: string, + remove: boolean, +) => Promise; + +type SystemRowProps = { + currentPubkey?: string; + entries?: MainTimelineEntry[]; + entry?: MainTimelineEntry; + footer: React.ReactNode; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + ownerProfiles?: UserProfileLookup; +}; + +export function SystemRow({ + currentPubkey, + entries, + entry, + footer, + onToggleReaction, + profiles, + ownerProfiles, +}: SystemRowProps) { + const systemEntries = entries ?? (entry ? [entry] : []); + const firstEntry = systemEntries[0]; + const groupedMessages = React.useMemo( + () => entries?.map((systemEntry) => systemEntry.message), + [entries], + ); + if (!firstEntry) return null; + + return ( +
+ + {footer} +
+ ); +} + +type MessageRowItemProps = { + channelId?: string | null; + currentPubkey?: string; + entry: MainTimelineEntry; + followThreadById?: (rootId: string) => void; + footer: React.ReactNode; + highlightedMessageId?: string | null; + huddleMemberPubkeys?: readonly string[]; + huddleMemberPubkeysPending?: boolean; + hideAgentAccessBadges?: boolean; + isContinuation?: boolean; + isFollowedByContinuation?: boolean; + isFollowingThreadById?: (rootId: string) => boolean; + isUnread?: boolean; + playEntrance?: boolean; + onEntranceComplete?: (messageId: string) => void; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; + onMarkRead?: (message: TimelineMessage) => void; + onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + searchActiveMessageId?: string | null; + searchMatchingMessageIds?: Set; + searchQuery?: string; + threadUnreadCounts?: ReadonlyMap; + unfollowThreadById?: (rootId: string) => void; + videoReviewContext: ReturnType; +}; + +export function MessageRowItem({ + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId, + huddleMemberPubkeys, + huddleMemberPubkeysPending, + hideAgentAccessBadges, + isContinuation = false, + isFollowedByContinuation = false, + isFollowingThreadById, + isUnread, + playEntrance = false, + onEntranceComplete, + onDelete, + onEdit, + onMarkUnread, + onMarkRead, + onReply, + onOpenThread, + onToggleReaction, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + threadUnreadCounts, + unfollowThreadById, + videoReviewContext, +}: MessageRowItemProps) { + const { message, summary } = entry; + const canManage = canManageMessageForCurrentUser( + message, + currentPubkey, + profiles, + ); + const canDelete = canManage && onDelete ? onDelete : undefined; + const canEdit = canManage && onEdit ? onEdit : undefined; + + if (summary && onOpenThread) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkRead={onMarkRead} + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + showDepthGuides={false} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); + } + + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} diff --git a/desktop/src/features/messages/ui/TimelineRowShell.tsx b/desktop/src/features/messages/ui/TimelineRowShell.tsx new file mode 100644 index 000000000..f69f461c8 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineRowShell.tsx @@ -0,0 +1,27 @@ +import type * as React from "react"; +import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; +import { + getTimelineItemKey, + type TimelineNonDayItem, +} from "@/features/messages/lib/timelineItems"; +import { cn } from "@/shared/lib/cn"; + +export function TimelineRowShell({ + children, + item, + useContentVisibility = true, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; + useContentVisibility?: boolean; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts new file mode 100644 index 000000000..8edeea615 --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -0,0 +1,135 @@ +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +type EditDraft = { + content: string; + mentionRefs: DraftMentionRef[]; + pendingImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + spoileredAttachmentUrls: Set; +}; + +type SubmitMessageEditOptions = Omit & { + clearComposer: () => void; + customEmoji: ReadonlyArray; + extractMentionPubkeys: (content: string) => string[]; + getMentionRefs: (content: string) => DraftMentionRef[]; + editTargetId: string; + originalContent: string; + ownerPubkey: string | null; + restoreComposer: (draft: EditDraft) => void; + restoreMentionRefs: (refs: DraftMentionRef[]) => void; + shouldRestoreComposer: () => boolean; + setDeferredUploadPending: (isPending: boolean) => void; + save: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + eventId?: string, + ) => Promise; + setUploadError: (message: string) => void; +}; + +/** Clear an edited message immediately, then upload and save captured state. */ +export async function submitMessageEdit({ + clearComposer, + content, + customEmoji, + editTargetId, + extractMentionPubkeys, + getMentionRefs, + originalContent, + ownerPubkey, + pendingImeta, + queuedAttachments, + restoreComposer, + restoreMentionRefs, + setDeferredUploadPending, + shouldRestoreComposer, + save, + setUploadError, + spoileredAttachmentUrls, +}: SubmitMessageEditOptions): Promise { + const draft: EditDraft = { + content, + mentionRefs: getMentionRefs(content), + pendingImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; + const restoreDraft = () => { + if (shouldRestoreComposer()) { + restoreComposer(draft); + restoreMentionRefs(draft.mentionRefs); + } + }; + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeys(originalContent), + extractMentionPubkeys(content), + ownerPubkey ?? "", + ); + const hasQueuedAttachments = draft.queuedAttachments.length > 0; + if (hasQueuedAttachments) setDeferredUploadPending(true); + clearComposer(); + + const finishEdit = async (uploaded: ImetaMedia[], signal?: AbortSignal) => { + // An explicit empty media tag set tells edit receivers to wipe attachments. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + content, + [...draft.pendingImeta, ...uploaded], + new Set([ + ...draft.spoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] ? [uploaded[index].url] : [], + ), + ]), + ); + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ) ?? []; + if (signal?.aborted) return; + await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); + }; + + if (hasQueuedAttachments) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded, signal) => { + try { + await finishEdit(uploaded, signal); + } catch { + restoreDraft(); + } finally { + setDeferredUploadPending(false); + } + }, + onError: (error) => { + restoreDraft(); + setUploadError(String(error)); + setDeferredUploadPending(false); + }, + onCancel: () => { + restoreDraft(); + setDeferredUploadPending(false); + }, + }); + return; + } + + try { + await finishEdit([]); + } catch { + restoreDraft(); + } +} diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index 9990ce506..6035ed736 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -40,6 +40,11 @@ export function useComposerHeightPadding( return; } + // In CSS-variable mode the timeline controls are siblings of the scroll + // element. Set the measurement on their shared parent so both the virtual + // trailing spacer and floating controls inherit the same live height. + const cssVariableTarget = scrollEl.parentElement ?? scrollEl; + const getScrollElement = (): HTMLElement => mode === "css-variable" ? (scrollEl.querySelector( @@ -79,7 +84,10 @@ export function useComposerHeightPadding( const wasAtBottom = isNearBottom(); if (mode === "css-variable") { - scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`); + cssVariableTarget.style.setProperty( + "--composer-overlay-height", + `${padding}px`, + ); } else { scrollEl.style.paddingBottom = `${padding}px`; } @@ -116,7 +124,7 @@ export function useComposerHeightPadding( cancelAnimationFrame(followBottomFrame); } if (mode === "css-variable") { - scrollEl.style.removeProperty("--composer-overlay-height"); + cssVariableTarget.style.removeProperty("--composer-overlay-height"); } else { scrollEl.style.paddingBottom = ""; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index e2c4134bd..14dae33ad 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { DraftMentionRef, DraftState, @@ -28,6 +29,19 @@ type UseDraftPersistLifecycleParams = { livePendingImeta: ImetaMedia[]; /** Async setter for pendingImeta — called after the synchronous snapshot. */ setPendingImeta: (imeta: ImetaMedia[]) => void; + /** Snapshot the local files owned by the outgoing draft key. */ + getQueuedAttachments?: () => QueuedMediaAttachment[]; + /** Retain local files in memory under their draft key. */ + saveQueuedAttachmentsForDraft?: ( + draftKey: string, + attachments: QueuedMediaAttachment[], + ) => void; + /** Local files cannot be persisted, so clear them at a draft-key boundary. */ + clearQueuedAttachments?: () => void; + /** Restore local files retained while a deferred upload was off-channel. */ + restoreQueuedAttachments?: (attachments: QueuedMediaAttachment[]) => void; + /** Read and remove local files retained for a recovered draft. */ + takeQueuedAttachmentsForDraft?: (draftKey: string) => QueuedMediaAttachment[]; /** Set the rich-text editor content from a draft string. */ setContent: (content: string) => void; /** Clear the rich-text editor content (no-draft path). */ @@ -80,6 +94,11 @@ export function useDraftPersistLifecycle({ restoreMentionRefs, livePendingImeta, setPendingImeta, + getQueuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments, + restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent, clearContent, setSpoileredAttachmentUrls, @@ -87,6 +106,12 @@ export function useDraftPersistLifecycle({ syncComposerContentFromEditor, }: UseDraftPersistLifecycleParams): void { const pendingImetaForPersistRef = React.useRef([]); + const restoredQueuedAttachmentsRef = React.useRef( + [], + ); + const restoredQueuedAttachmentsDraftKeyRef = React.useRef( + null, + ); // Render-time update: keep the ref in sync with committed state so the // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; @@ -99,6 +124,16 @@ export function useDraftPersistLifecycle({ // already reflects the incoming channel, which would corrupt the outgoing // draft's channelId metadata. + // Files cannot be serialized into localStorage. Replace the outgoing + // queue (retained by the cleanup below) with the incoming draft's queue. + clearQueuedAttachments?.(); + if (effectiveDraftKey !== restoredQueuedAttachmentsDraftKeyRef.current) { + restoredQueuedAttachmentsDraftKeyRef.current = effectiveDraftKey ?? null; + restoredQueuedAttachmentsRef.current = effectiveDraftKey + ? (takeQueuedAttachmentsForDraft?.(effectiveDraftKey) ?? []) + : []; + } + restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; if (saved) { setContent(saved.content); @@ -121,6 +156,10 @@ export function useDraftPersistLifecycle({ return () => { if (effectiveDraftKey) { + const queuedAttachments = getQueuedAttachments?.() ?? []; + if (queuedAttachments.length > 0) { + saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); + } const content = syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts new file mode 100644 index 000000000..76503bfab --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -0,0 +1,76 @@ +import type { ManagedAgent } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; + +export { MENTION_REFERENCE_TAG }; + +export type PendingNonMemberMentionSend = { + capturedChannelId: string | null; + capturedThreadContext: { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + trimmed: string; + mentionPubkeys: string[]; + nonMemberPubkeys: string[]; + outgoingTags?: string[][]; + preparedManagedAgents?: ManagedAgent[]; + readyAgentPubkeys?: string[]; + savedContent: string; + savedImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + savedSpoileredAttachmentUrls: Set; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + savedMentionRefs: DraftMentionRef[]; + audienceGeneration: number; + audienceRevision: number | null; + explicitAgentPubkeys: string[]; +}; + +export type SendMessageWithMentionFlowInput = { + capturedChannelId: string | null; + capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; + pendingImeta: ImetaMedia[]; + queuedAttachments?: QueuedMediaAttachment[]; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + spoileredAttachmentUrls?: ReadonlySet; + trimmed: string; + audienceGeneration?: number; + audienceRevision?: number | null; +}; + +export function mergeOutgoingTagsWithReferenceMentions( + outgoingTags: string[][] | undefined, + pubkeys: Iterable, +) { + const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); + if (normalizedPubkeys.length === 0) { + return outgoingTags; + } + + return [ + ...(outgoingTags ?? []), + ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), + ]; +} + +export function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +export function uniqueNormalizedPubkeys(pubkeys: Iterable) { + return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); +} + +export function isManagedAgentRunning(agent: ManagedAgent) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function isProviderBackedAgent(agent: ManagedAgent) { + return agent.backend.type === "provider"; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5e9ef2792..6ba9f6905 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,6 +1,5 @@ import * as React from "react"; import { toast } from "sonner"; - import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, @@ -13,6 +12,11 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import { + prepareBackgroundMediaUpload, + saveQueuedAttachmentsForDraft, + type QueuedMediaAttachment, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -27,54 +31,24 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; - -type PendingNonMemberMentionSend = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - finalContent: string; - mentionPubkeys: string[]; - nonMemberPubkeys: string[]; - outgoingTags?: string[][]; - preparedManagedAgents?: ManagedAgent[]; - readyAgentPubkeys?: string[]; - savedContent: string; - savedImeta: ImetaMedia[]; - savedSpoileredAttachmentUrls: Set; - sentDraftKey: string | null | undefined; - audienceGeneration: number; - audienceRevision: number | null; - /** Agent mentions explicitly authored in this draft (never inferred). */ - explicitAgentPubkeys: string[]; -}; - -type SendMessageWithMentionFlowInput = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - pendingImeta: ImetaMedia[]; - sentDraftKey: string | null | undefined; - spoileredAttachmentUrls?: ReadonlySet; - trimmed: string; - audienceGeneration?: number; - audienceRevision?: number | null; -}; - +import { + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + MENTION_REFERENCE_TAG, + mergeOutgoingTagsWithReferenceMentions, + type PendingNonMemberMentionSend, + type SendMessageWithMentionFlowInput, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; type UseMentionSendFlowOptions = { channelId: string | null; channelLinks: Pick; channelType: ChannelType | null; contentRef: React.MutableRefObject; customEmoji: CustomEmoji[]; - drafts: Pick; + drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; onPrepareSendChannel?: ( @@ -99,6 +73,9 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -110,43 +87,10 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; - -function mergeOutgoingTagsWithReferenceMentions( - outgoingTags: string[][] | undefined, - pubkeys: Iterable, -) { - const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); - if (normalizedPubkeys.length === 0) { - return outgoingTags; - } - - return [ - ...(outgoingTags ?? []), - ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), - ]; -} - -function getErrorMessage(error: unknown, fallback: string) { - return error instanceof Error && error.message ? error.message : fallback; -} - -function uniqueNormalizedPubkeys(pubkeys: Iterable) { - return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); -} - -function isManagedAgentRunning(agent: ManagedAgent) { - return agent.status === "running" || agent.status === "deployed"; -} - -function isProviderBackedAgent(agent: ManagedAgent) { - return agent.backend.type === "provider"; -} - const DM_THREAD_AGENT_MENTION_ERROR = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; const DM_THREAD_MEMBERS_LOADING_ERROR = "Checking conversation members. Try again in a moment."; - export function useMentionSendFlow({ channelId, channelLinks, @@ -162,6 +106,9 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + hasUnsavedMedia, + clearQueuedAttachments, + restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -188,7 +135,6 @@ export function useMentionSendFlow({ isMountedRef.current = false; }; }, []); - const addMembersMutation = useAddChannelMembersMutation(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = @@ -198,18 +144,15 @@ export function useMentionSendFlow({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const startAgentMutation = useStartManagedAgentMutation(); - const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = managedAgentsQuery.data ?? (await managedAgentsQuery.refetch()).data ?? []; - return new Map( agents.map((agent) => [normalizePubkey(agent.pubkey), agent]), ); }, [managedAgentsQuery.data, managedAgentsQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< AcpRuntime[] > => { @@ -217,7 +160,6 @@ export function useMentionSendFlow({ if (cached.length > 0 || !availableRuntimesQuery.isLoading) { return cached; } - const refetched = await availableRuntimesQuery.refetch(); return (refetched.data ?? []).filter( (runtime): runtime is AcpRuntime => @@ -230,7 +172,6 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -399,6 +340,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -416,6 +358,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -442,12 +385,33 @@ export function useMentionSendFlow({ isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); + const preparedUpload = + draft.queuedAttachments.length > 0 + ? prepareBackgroundMediaUpload(draft.queuedAttachments) + : null; + const persistPreflightDraft = () => { + if (!draft.recoveryDraftKey) return; + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, + ); + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + }; + let uploadStarted = false; try { const readyAgentPubkeys = new Set( (draft.readyAgentPubkeys ?? []).map(normalizePubkey), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); if (!isMountedRef.current) { + persistPreflightDraft(); return; } for (const agent of draft.preparedManagedAgents ?? []) { @@ -473,6 +437,7 @@ export function useMentionSendFlow({ return; } if (!isMountedRef.current) { + persistPreflightDraft(); return; } } @@ -486,6 +451,7 @@ export function useMentionSendFlow({ [...managedAgentsByPubkey.values()], ); if (!isMountedRef.current) { + persistPreflightDraft(); return; } if (agentReadiness.errors.length > 0) { @@ -523,23 +489,87 @@ export function useMentionSendFlow({ mentionPubkeys, ); - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. - if (draft.capturedChannelId === channelIdRef.current) { - clearComposer( - resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + const send = onSendRef.current; + const persistCanceledDraft = () => { + if (!draft.recoveryDraftKey) return; + const existing = drafts.loadDraft(draft.recoveryDraftKey); + if ( + existing && + (existing.content !== draft.savedContent || + existing.channelId !== + (draft.capturedChannelId ?? draft.recoveryDraftKey) || + JSON.stringify(existing.pendingImeta) !== + JSON.stringify(draft.savedImeta) || + JSON.stringify(existing.spoileredAttachmentUrls) !== + JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + ) { + return; + } + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, ); - } - - try { - await onSendRef.current( - draft.finalContent, + }; + const restoreComposerAfterFailure = () => { + persistCanceledDraft(); + const canRestoreCurrentComposer = + isMountedRef.current && + (draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null) && + contentRef.current.trim().length === 0 && + !hasUnsavedMedia(); + if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + } + if (!canRestoreCurrentComposer) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + mentions.restoreDraftMentionRefs(draft.savedMentionRefs); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + const finishSend = async ( + uploaded: ImetaMedia[], + signal?: AbortSignal, + ) => { + const { content: finalContent, mediaTags } = buildOutgoingMessage( + draft.trimmed, + [...draft.savedImeta, ...uploaded], + new Set([ + ...draft.savedSpoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] + ? [uploaded[index].url] + : [], + ), + ]), + ); + const finalOutgoingTags = mergeOutgoingTags( + mediaTags, + outgoingTags ?? [], + ); + if (signal?.aborted) return; + await send( + finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); + if (signal?.aborted) return; if (effectiveExplicitAgentPubkeys.length > 0) { // Promote only explicitly authored agents that remained effective // for this successful send. "Send without inviting" removes its @@ -555,25 +585,57 @@ export function useMentionSendFlow({ drafts.markDraftSent( draft.sentDraftKey, draft.savedContent, - sendChannelId ?? draft.sentDraftKey, + draft.capturedChannelId ?? draft.sentDraftKey, draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], ); } - } catch { - // Only restore the composer content if the user is still on the - // channel that originated the send. - if (draft.capturedChannelId === channelIdRef.current) { - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); + }; + if (preparedUpload) { + uploadStarted = preparedUpload.start({ + onComplete: async (uploaded, signal) => { + try { + await finishSend(uploaded, signal); + } catch { + restoreComposerAfterFailure(); + } + }, + onError: (error) => { + restoreComposerAfterFailure(); + toast.error( + `Upload failed: ${getErrorMessage(error, "Unknown error")}`, + ); + }, + onCancel: () => { + restoreComposerAfterFailure(); + }, + }); + if (!uploadStarted) { + return; + } + } + + // Replace the sent body directly with its final post-send state before + // the async network send starts. This avoids an intermediate blank frame + // for persistent audiences while preserving the ordinary empty state. + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + clearComposer( + resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + ); + } + + if (!preparedUpload) { + try { + await finishSend([]); + } catch { + restoreComposerAfterFailure(); } } } finally { + if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { setIsCompleteSendPending(false); @@ -594,7 +656,10 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + restoreQueuedAttachments, setSpoileredAttachmentUrls, + hasUnsavedMedia, + mentions.restoreDraftMentionRefs, ], ); @@ -660,7 +725,9 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, + queuedAttachments = [], sentDraftKey, + recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, audienceGeneration = 0, @@ -721,15 +788,7 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - pendingImeta, - spoileredAttachmentUrls, - ); - const outgoingTags = mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ); + const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => @@ -752,7 +811,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - finalContent, + trimmed, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -763,8 +822,11 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, + recoveryDraftKey, + savedMentionRefs: mentions.getDraftMentionRefs(trimmed), audienceGeneration, audienceRevision, explicitAgentPubkeys, @@ -793,6 +855,7 @@ export function useMentionSendFlow({ mentions.extractMentionPubkeys, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, + mentions.getDraftMentionRefs, onPrepareSendChannel, ], ); 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/hooks.ts b/desktop/src/features/notifications/hooks.ts index f4337ce4c..72d1a0338 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -378,6 +378,7 @@ export function useHomeFeedNotificationState( // has not been advanced by opening Home. getMessageReadAt: (messageId: string) => number | null = () => null, channels: ReadonlyArray> = [], + silentChannelIds?: ReadonlySet, ) { useFeedDesktopNotifications( feed, @@ -388,6 +389,7 @@ export function useHomeFeedNotificationState( profiles, mutedChannelIds, channels, + silentChannelIds, ); const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const [seenFeedIds, setSeenFeedIds] = React.useState(() => 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/notifications/lib/sound.test.mjs b/desktop/src/features/notifications/lib/sound.test.mjs new file mode 100644 index 000000000..dfa9c8406 --- /dev/null +++ b/desktop/src/features/notifications/lib/sound.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldPlayNotificationSound } from "./sound.ts"; + +test("silences notifications from Huddle backing channels", () => { + const silentChannelIds = new Set(["active-huddle"]); + + assert.equal( + shouldPlayNotificationSound("active-huddle", silentChannelIds), + false, + ); + assert.equal( + shouldPlayNotificationSound("ordinary-channel", silentChannelIds), + true, + ); + assert.equal(shouldPlayNotificationSound(null, silentChannelIds), true); +}); diff --git a/desktop/src/features/notifications/lib/sound.ts b/desktop/src/features/notifications/lib/sound.ts index e9fa8cb9f..1e9ccb383 100644 --- a/desktop/src/features/notifications/lib/sound.ts +++ b/desktop/src/features/notifications/lib/sound.ts @@ -128,6 +128,13 @@ export function slotForFeedKind( return "needs_action"; } +export function shouldPlayNotificationSound( + channelId: string | null | undefined, + silentChannelIds?: ReadonlySet, +): boolean { + return !channelId || !silentChannelIds?.has(channelId); +} + const cache = new Map(); function getAudio(name: SoundName): HTMLAudioElement { diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 0415a29c9..b58e260ec 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -22,6 +22,7 @@ import { import { playNotificationSound, resolveSlotSound, + shouldPlayNotificationSound, slotForFeedKind, } from "./lib/sound"; import type { NotificationSettings } from "./hooks"; @@ -77,6 +78,7 @@ export function useFeedDesktopNotifications( profiles?: UserProfileLookup, mutedChannelIds?: ReadonlySet, channels: readonly NotificationChannel[] = [], + silentChannelIds?: ReadonlySet, ) { const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const seenItemIdsRef = React.useRef>( @@ -126,7 +128,10 @@ export function useFeedDesktopNotifications( title: notificationTitle(item, senderName), }); - if (didSend) { + if ( + didSend && + shouldPlayNotificationSound(item.channelId, silentChannelIds) + ) { const slot = slotForFeedKind(item.kind, item.category); playNotificationSound(resolveSlotSound(settings, slot)); } 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/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index da51ab1b8..06295cc61 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -60,6 +60,8 @@ type UserProfilePopoverProps = { triggerAriaLabel?: string; /** Set false when the trigger is inside another interactive control. */ enableProfilePanel?: boolean; + /** Set false when a smaller, context-specific hover treatment is provided. */ + enableHoverPopover?: boolean; /** When set to "bot", a BotIdenticon badge renders next to the display name. */ role?: string; /** Value used to generate the BotIdenticon glyph (typically the author name). */ @@ -174,6 +176,7 @@ export function UserProfilePopover({ triggerElement = "div", triggerAriaLabel, enableProfilePanel = true, + enableHoverPopover = true, role, botIdenticonValue, }: UserProfilePopoverProps) { @@ -298,11 +301,14 @@ export function UserProfilePopover({ }, []); const handleTriggerMouseEnter = React.useCallback(() => { + if (!enableHoverPopover) { + return; + } clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); }, HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer]); + }, [clearHoverTimer, enableHoverPopover]); const handleMouseLeave = React.useCallback(() => { clearHoverTimer(); @@ -593,6 +599,11 @@ export function UserProfilePopover({ data-testid="user-profile-popover" onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave} + // This is a hover card: moving focus into its first button on open + // makes the profile header look keyboard-selected before the user has + // interacted with it. Keep focus on the trigger; Tab still enters the + // card and shows its normal focus treatment when needed. + onOpenAutoFocus={(event) => event.preventDefault()} side="top" sideOffset={8} > diff --git a/desktop/src/features/projects/branchMutations.ts b/desktop/src/features/projects/branchMutations.ts index 55874dc77..647e6fac2 100644 --- a/desktop/src/features/projects/branchMutations.ts +++ b/desktop/src/features/projects/branchMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; import { createProjectRemoteBranch, deleteProjectRemoteBranch, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d47..f6e5d2f1b 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -23,7 +23,6 @@ import { KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; @@ -39,10 +38,11 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; -import { resolveProjectDefaultBranch } from "./lib/projectBranches"; -import { effectiveCloneUrls } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; -import { projectIssueEventsToIssues } from "./projectIssues.mjs"; +import { + nextProjectIssueCommentCreatedAt, + projectIssueEventsToIssues, +} from "./projectIssues.mjs"; import type { ProjectPullRequest, ProjectPullRequestCommentAnchor, @@ -55,33 +55,29 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { + eventToRepository, + type Project, + type Repository, +} from "./projectModels"; +import { + buildProjectsFromFetcher, + fetchProjectEventsExhaustively, +} from "./projectEnumeration"; +import { projectMatchesRouteId } from "./projectRoutes"; export type { + Project, ProjectIssue, ProjectPullRequest, ProjectPullRequestCommentAnchor, + Repository, }; export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; -export type Project = { - id: string; - dtag: string; - name: string; - description: string; - cloneUrls: string[]; - webUrl: string | null; - owner: string; - contributors: string[]; - createdAt: number; - projectChannelId: string | null; - status: string; - defaultBranch: string; - repoAddress: string; -}; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -120,34 +116,16 @@ export type { export type ProjectPullRequestListItem = { project: Project; + repository: Repository; pullRequest: ProjectPullRequest; }; export type ProjectIssueListItem = { project: Project; + repository: Repository; issue: ProjectIssue; }; -function getTag(event: RelayEvent, name: string): string | undefined { - const value = event.tags.find((t) => t[0] === name)?.[1]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function getAllTags(event: RelayEvent, name: string): string[] { - return event.tags - .filter((t) => t[0] === name && typeof t[1] === "string" && t[1].length > 0) - .map((t) => t[1]); -} - -function getCloneUrls(event: RelayEvent): string[] { - const tag = event.tags.find((t) => t[0] === "clone"); - return tag ? tag.slice(1) : []; -} - -function projectCoordinate(project: Pick): string { - return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; -} - function readHiddenProjectCards(): string[] { if (typeof window === "undefined") { return []; @@ -165,21 +143,6 @@ function readHiddenProjectCards(): string[] { } } -function isHiddenLocally(project: Project): boolean { - return readHiddenProjectCards().includes(projectCoordinate(project)); -} - -function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { - const coordinate = projectCoordinate(project); - // NIP-09: a deletion is only valid when signed by the author of the - // referenced event — otherwise anyone could hide someone else's project. - return deletionEvents.some( - (event) => - event.pubkey.toLowerCase() === project.owner.toLowerCase() && - event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), - ); -} - /** * Converts a kind:30617 repo announcement into a `Project`. * @@ -191,136 +154,27 @@ function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { export function eventToProject( event: RelayEvent, relayOrigin?: string | null, -): Project { - const d = getTag(event, "d") ?? event.id; - const name = getTag(event, "name") || d; - const description = getTag(event, "description") || event.content || ""; - const cloneUrls = effectiveCloneUrls( - getCloneUrls(event), - relayOrigin, - event.pubkey, - d, - ); - const webUrl = getTag(event, "web") ?? null; - const setupUsers = getAllTags(event, "auth"); - const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])]; - // `h`/`project-channel`, `status`, and `default-branch` are NOT part of - // NIP-34 — they are read-side tolerance for extension tags no code writes - // today (the write path that emitted them was removed). If a write path is - // reintroduced it must go through the buzz-sdk repo-announcement builder; - // the canonical NIP-34 source for the default branch is the kind:30618 - // state event's HEAD ref, not a 30617 tag. - const projectChannelId = - getTag(event, "h") ?? getTag(event, "project-channel") ?? null; - - return { - id: `${event.pubkey}:${d}`, - dtag: d, - name, - description, - cloneUrls, - webUrl, - owner: event.pubkey, - contributors, - createdAt: event.created_at, - projectChannelId, - status: getTag(event, "status") ?? "active", - defaultBranch: getTag(event, "default-branch") ?? "main", - repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }), - }; -} - -function dedup(events: RelayEvent[]): RelayEvent[] { - const best = new Map(); - - for (const e of events) { - const d = getTag(e, "d") ?? ""; - const key = `${e.pubkey}:${e.kind}:${d}`; - const prev = best.get(key); - - if (!prev || e.created_at > prev.created_at) { - best.set(key, e); - } +): Repository { + const repository = eventToRepository(event, relayOrigin); + if (!repository) { + throw new Error("Invalid repository announcement."); } - - return [...best.values()]; + return repository; } -export async function fetchProjects(): Promise { - const [events, deletionEvents] = await Promise.all([ - relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - limit: 200, - }), - relayClient.fetchEvents({ - kinds: [KIND_DELETION], - limit: 500, - }), - ]); - - return dedup(events) - .map((event) => eventToProject(event, getCachedRelayOrigin())) - .filter( - (project) => - !isHiddenLocally(project) && !isDeletedByA(project, deletionEvents), - ) - .sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * Splits a project route ID into its owner pubkey and dtag. The canonical - * form is `:` (matching `Project.id`) — NIP-34 repo - * identity is the full `30617::` coordinate, and two owners can - * both publish the same dtag (forks). Bare-dtag IDs from legacy links are - * still resolved, ambiguously, to whichever owner the relay returns first. - */ -function parseProjectRouteId(projectId: string): { - owner: string | null; - dtag: string; -} { - const owner = projectId.slice(0, 64); - if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { - return { owner: owner.toLowerCase(), dtag: projectId.slice(65) }; - } - return { owner: null, dtag: projectId }; -} - -async function fetchProject(projectId: string): Promise { - const { owner, dtag } = parseProjectRouteId(projectId); - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - ...(owner ? { authors: [owner] } : {}), - "#d": [dtag], - limit: 10, +export async function fetchProjects( + fetchExhaustively: ( + kinds: number[], + ) => Promise = fetchProjectEventsExhaustively, +): Promise { + // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which + // is the pure, Tauri-free core of this operation. That helper's javadoc + // explains the fail-closed tombstone contract and the NIP-OA owner-deletion + // relay-side-suppression decision. + return buildProjectsFromFetcher(fetchExhaustively, { + relayOrigin: getCachedRelayOrigin(), + hiddenAddresses: new Set(readHiddenProjectCards()), }); - - const deduped = dedup(events).filter( - (event) => !owner || event.pubkey.toLowerCase() === owner, - ); - const project = - deduped.length > 0 - ? eventToProject(deduped[0], getCachedRelayOrigin()) - : null; - if (!project) { - return null; - } - - const deletionEvents = await relayClient.fetchEvents({ - kinds: [KIND_DELETION], - authors: [project.owner], - "#a": [project.repoAddress], - limit: 10, - }); - - if (isDeletedByA(project, deletionEvents)) return null; - const repoState = await fetchRepoState(project); - return { - ...project, - defaultBranch: resolveProjectDefaultBranch( - project.defaultBranch, - repoState, - ), - }; } function eventToRepoState(event: RelayEvent): RepoState { @@ -349,7 +203,7 @@ function eventToRepoState(event: RelayEvent): RepoState { }; } -async function fetchRepoState(project: Project): Promise { +async function fetchRepoState(project: Repository): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( @@ -368,7 +222,9 @@ async function fetchRepoState(project: Project): Promise { return events.length > 0 ? eventToRepoState(events[0]) : null; } -async function fetchProjectIssues(project: Project): Promise { +async function fetchProjectIssues( + project: Repository, +): Promise { const [issueEvents, statusEvents, commentEvents] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_GIT_ISSUE], @@ -396,7 +252,7 @@ async function fetchProjectIssues(project: Project): Promise { } async function fetchProjectPullRequests( - project: Project, + project: Repository, ): Promise { const [pullRequestEvents, updateEvents, commentEvents, statusEvents] = await Promise.all([ @@ -454,7 +310,7 @@ async function createProjectPullRequestComment({ decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; - project: Project; + project: Repository; pullRequest: ProjectPullRequest; }): Promise { const body = content.trim(); @@ -531,7 +387,7 @@ async function createProjectIssueComment({ mediaTags?: string[][]; mentionPubkeys?: string[]; issue: ProjectIssue; - project: Project; + project: Repository; }): Promise { const body = content.trim(); if (!body) { @@ -550,10 +406,16 @@ async function createProjectIssueComment({ ...[...recipients].map((recipient) => ["p", recipient]), ...(mediaTags ?? []), ]; + const identity = await getIdentity(); const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, content: body, + createdAt: nextProjectIssueCommentCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + identity.pubkey, + ), tags, }); @@ -565,7 +427,7 @@ async function createProjectIssueComment({ } async function fetchProjectRepoSnapshot( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, @@ -587,7 +449,7 @@ async function fetchProjectRepoSnapshot( } async function fetchProjectRepoDiff( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, ): Promise { @@ -604,7 +466,7 @@ async function fetchProjectRepoDiff( } async function fetchProjectLocalRepoDiff( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -625,7 +487,7 @@ async function fetchProjectLocalRepoDiff( } async function fetchProjectLocalRepoSnapshot( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, ): Promise { @@ -638,11 +500,11 @@ async function fetchProjectLocalRepoSnapshot( }); } -async function fetchProjectActivitySummaries( - projects: Project[], +/** Loads commit, pull-request, and issue activity keyed by repository address. */ +export async function fetchRepositoryActivitySummaries( + repositories: Repository[], ): Promise> { - if (projects.length === 0) return {}; - + if (repositories.length === 0) return {}; const events = await relayClient.fetchEvents({ kinds: [ KIND_GIT_ISSUE, @@ -654,26 +516,100 @@ async function fetchProjectActivitySummaries( KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, ], - "#a": projects.map((project) => project.repoAddress), + "#a": repositories.map((repository) => repository.repoAddress), limit: 1_000, }); - return summarizeProjectActivityEvents(events, projects) as Record< + return summarizeProjectActivityEvents(events, repositories) as Record< string, ProjectActivitySummary >; } +async function fetchProjectActivitySummaries( + projects: Project[], +): Promise> { + if (projects.length === 0) return {}; + + const repositories = [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ]; + const summariesByRepository = + await fetchRepositoryActivitySummaries(repositories); + return Object.fromEntries( + projects.map((project) => { + const summaries = project.repositories.map( + (repository) => summariesByRepository[repository.repoAddress], + ); + const latestCommit = + summaries + .map((summary) => summary?.latestCommit) + .filter( + ( + commit, + ): commit is NonNullable => + Boolean(commit), + ) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? null; + const activityByDay: Record = {}; + for (const summary of summaries) { + for (const [day, count] of Object.entries( + summary?.activityByDay ?? {}, + )) { + activityByDay[day] = (activityByDay[day] ?? 0) + count; + } + } + return [ + project.id, + { + repoAddress: project.projectAddress, + issueCount: summaries.reduce( + (count, summary) => count + (summary?.issueCount ?? 0), + 0, + ), + prCount: summaries.reduce( + (count, summary) => count + (summary?.prCount ?? 0), + 0, + ), + commitCount: summaries.reduce( + (count, summary) => count + (summary?.commitCount ?? 0), + 0, + ), + activityCount: summaries.reduce( + (count, summary) => count + (summary?.activityCount ?? 0), + 0, + ), + updatedAt: Math.max( + 0, + ...summaries.map((summary) => summary?.updatedAt ?? 0), + ), + participantPubkeys: [ + ...new Set( + summaries.flatMap((summary) => summary?.participantPubkeys ?? []), + ), + ], + latestCommit, + activityByDay, + } satisfies ProjectActivitySummary, + ]; + }), + ); +} + async function deleteProject(project: Project): Promise { const identity = await getIdentity(); if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { - throw new Error("Only branch owners can delete branches."); + throw new Error("Only the project owner can delete this project."); } const event = await signRelayEvent({ kind: KIND_DELETION, content: `Delete project ${project.name}`, - tags: [["a", project.repoAddress]], + tags: [["a", project.projectAddress]], }); await relayClient.publishEvent( @@ -688,20 +624,23 @@ export const projectsQueryKey = ["projects"] as const; export function useProjectsQuery() { return useQuery({ queryKey: projectsQueryKey, - queryFn: fetchProjects, + queryFn: () => fetchProjects(), staleTime: 60_000, }); } export function useProjectQuery(projectId: string) { return useQuery({ - queryKey: ["project", projectId], - queryFn: () => fetchProject(projectId), + queryKey: projectsQueryKey, + queryFn: () => fetchProjects(), + select: (projects) => + projects.find((project) => projectMatchesRouteId(project, projectId)) ?? + null, staleTime: 60_000, }); } -export function useRepoStateQuery(project: Project | null | undefined) { +export function useRepoStateQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "repo-state"], @@ -714,15 +653,16 @@ export function useRepoStateQuery(project: Project | null | undefined) { } export function useProjectRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, + enabled = true, ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(enabled && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", @@ -748,7 +688,7 @@ export function useProjectRepoSnapshotQuery( } export function useProjectRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, enabled = true, @@ -775,7 +715,7 @@ export function useProjectRepoDiffQuery( } export function useProjectLocalRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -809,7 +749,7 @@ export function useProjectLocalRepoDiffQuery( } export function useProjectLocalRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, ) { @@ -842,7 +782,7 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { }); } -export function useProjectIssuesQuery(project: Project | null | undefined) { +export function useProjectIssuesQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "issues"], @@ -855,7 +795,7 @@ export function useProjectIssuesQuery(project: Project | null | undefined) { } export function useProjectPullRequestsQuery( - project: Project | null | undefined, + project: Repository | null | undefined, ) { return useQuery({ enabled: Boolean(project), @@ -879,7 +819,7 @@ export function useProjectsWorkItemsQuery(projects: Project[]) { } export function useCreateProjectIssueCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -919,7 +859,7 @@ export function useCreateProjectIssueCommentMutation( } export function useCreateProjectPullRequestCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -966,7 +906,12 @@ export function useCreateProjectPullRequestCommentMutation( export function useProjectActivitySummariesQuery(projects: Project[]) { const repoAddresses = React.useMemo( - () => projects.map((project) => project.repoAddress).sort(), + () => + projects + .flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ) + .sort(), [projects], ); @@ -987,11 +932,7 @@ export function useDeleteProjectMutation() { queryClient.setQueryData(projectsQueryKey, (current = []) => current.filter((item) => item.id !== project.id), ); - queryClient.setQueryData(["project", project.id], null); void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); - void queryClient.invalidateQueries({ - queryKey: ["project", project.id], - }); }, }); } diff --git a/desktop/src/features/projects/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts index 0d18e4722..57834f440 100644 --- a/desktop/src/features/projects/issueMutations.ts +++ b/desktop/src/features/projects/issueMutations.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import { KIND_GIT_ISSUE } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { buildGitIssueTags } from "./projectIssues.mjs"; type CreateProjectIssueInput = { diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index 6179c46a0..cc00d5f63 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts"; +import { + projectRepoHost, + projectRepoHostForProject, +} from "./projectRepoHost.ts"; const OWNER = "a".repeat(64); const ORIGIN = "https://relay.example"; @@ -60,3 +64,43 @@ test("effectiveCloneUrls derives a default when none is advertised", () => { test("effectiveCloneUrls returns empty when no default can be derived", () => { assert.deepEqual(effectiveCloneUrls([], null, OWNER, "repo"), []); }); + +test("projectRepoHost recognizes a canonical repository on the relay", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, ORIGIN), { + kind: "buzz", + }); +}); + +test("projectRepoHost identifies an external repository by host", () => { + assert.deepEqual( + projectRepoHost("https://github.com/block/buzz.git", ORIGIN), + { kind: "external", host: "github.com" }, + ); +}); + +test("projectRepoHost treats a non-repository relay path as external", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/other/path`, ORIGIN), { + kind: "external", + host: "relay.example", + }); +}); + +test("projectRepoHost fails closed while either URL is unresolved", () => { + assert.deepEqual(projectRepoHost(null, ORIGIN), { kind: "unresolved" }); + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, null), { + kind: "unresolved", + }); + assert.deepEqual(projectRepoHost("not a URL", ORIGIN), { + kind: "unresolved", + }); +}); + +test("projectRepoHostForProject recognizes an implicit relay repository", () => { + assert.deepEqual( + projectRepoHostForProject( + { cloneUrls: [], dtag: "buzz", owner: OWNER }, + ORIGIN, + ), + { kind: "buzz" }, + ); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.test.mjs b/desktop/src/features/projects/lib/projectGitError.test.mjs new file mode 100644 index 000000000..cc691bb0d --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectCloneErrorPresentation } from "./projectGitError.ts"; + +test("explains unsupported authenticated GitHub clones without exposing git output", () => { + assert.deepEqual( + projectCloneErrorPresentation( + new Error( + "Cloning into '/Users/person/repos/app'... remote: repository requires SSH certificate authentication. fatal: requested URL returned error: 403", + ), + "https://github.com/example/app.git", + ), + { + title: "Repository access required", + description: + "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials.", + }, + ); +}); + +test("presents missing and network failures clearly", () => { + assert.equal( + projectCloneErrorPresentation(new Error("Repository not found")).title, + "Repository not found", + ); + assert.equal( + projectCloneErrorPresentation(new Error("Could not resolve host")).title, + "Couldn’t reach the repository", + ); +}); + +test("uses a concise fallback", () => { + assert.deepEqual(projectCloneErrorPresentation(new Error("git failed")), { + title: "Couldn’t clone repository", + description: + "Try again. If the problem continues, contact the repository owner.", + }); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.ts b/desktop/src/features/projects/lib/projectGitError.ts new file mode 100644 index 000000000..b99933f1e --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.ts @@ -0,0 +1,72 @@ +export type ProjectGitErrorPresentation = { + title: string; + description: string; +}; + +function errorText(error: unknown) { + if (error instanceof Error) return error.message.toLowerCase(); + return typeof error === "string" ? error.toLowerCase() : ""; +} + +function isGitHubUrl(cloneUrl: string | null | undefined) { + try { + return new URL(cloneUrl ?? "").hostname.toLowerCase() === "github.com"; + } catch { + return false; + } +} + +export function projectCloneErrorPresentation( + error: unknown, + cloneUrl?: string | null, +): ProjectGitErrorPresentation { + const message = errorText(error); + const github = isGitHubUrl(cloneUrl); + + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test( + message, + ) + ) { + return { + title: "Repository access required", + description: github + ? "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials." + : "Buzz could not authenticate with this repository. Check your access and try again.", + }; + } + if (/\b404\b|repository not found|repository does not exist/.test(message)) { + return { + title: "Repository not found", + description: + "Check that the repository link is correct and that the repository still exists.", + }; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return { + title: "Couldn’t reach the repository", + description: "Check your connection and try cloning again.", + }; + } + if ( + /already exists and is not an empty directory|destination path .* exists/.test( + message, + ) + ) { + return { + title: "Local folder already exists", + description: + "Choose a different repositories directory or remove the existing checkout.", + }; + } + return { + title: "Couldn’t clone repository", + description: github + ? "Try again, or open the repository on GitHub for more information." + : "Try again. If the problem continues, contact the repository owner.", + }; +} diff --git a/desktop/src/features/projects/lib/projectLocalRepos.ts b/desktop/src/features/projects/lib/projectLocalRepos.ts index e89e32312..8380a1e19 100644 --- a/desktop/src/features/projects/lib/projectLocalRepos.ts +++ b/desktop/src/features/projects/lib/projectLocalRepos.ts @@ -1,4 +1,4 @@ -import type { Project } from "@/features/projects/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; function localRepoNameCandidate(value: string | null | undefined) { const trimmed = value?.trim().replace(/\.git$/i, "") ?? ""; @@ -26,7 +26,7 @@ function cloneUrlRepoName(cloneUrl: string | undefined) { } } -function localRepoCandidates(project: Project) { +function localRepoCandidates(project: Repository) { return [ localRepoNameCandidate(project.dtag), cloneUrlRepoName(project.cloneUrls[0]), @@ -39,7 +39,16 @@ export function hasLocalCheckout( project: Project, localRepoNames: Set, ) { - return localRepoCandidates(project).some((candidate) => + return project.repositories.some((repository) => + hasLocalRepositoryCheckout(repository, localRepoNames), + ); +} + +export function hasLocalRepositoryCheckout( + repository: Repository, + localRepoNames: Set, +) { + return localRepoCandidates(repository).some((candidate) => localRepoNames.has(candidate), ); } diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs new file mode 100644 index 000000000..b1a40ca9b --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts"; + +test("classifies a missing repository", () => { + assert.equal( + projectRepoUnavailableReason(new Error("remote: Repository not found")), + "missing", + ); + assert.equal(projectRepoUnavailableReason(null), "missing"); +}); + +test("classifies authentication failures before generic availability errors", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("The requested URL returned error: 403"), + ), + "authentication", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Authentication failed")), + "authentication", + ); +}); + +test("classifies branch and network failures", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("Remote branch main not found in upstream origin"), + ), + "ref", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Could not resolve host: relay")), + "network", + ); + assert.equal( + projectRepoUnavailableReason(new Error("git timed out after 300s")), + "network", + ); +}); + +test("keeps unmatched failures generic", () => { + assert.equal( + projectRepoUnavailableReason(new Error("git exited with status 128")), + "unknown", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts new file mode 100644 index 000000000..803548d3d --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -0,0 +1,48 @@ +export type ProjectRepoUnavailableReason = + | "missing" + | "authentication" + | "network" + | "ref" + | "unknown"; + +export function projectRepoUnavailableReason( + error: unknown, +): ProjectRepoUnavailableReason { + const message = + error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : ""; + + if (!message) return "missing"; + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied/.test( + message, + ) + ) { + return "authentication"; + } + if ( + /\b404\b|repository not found|repository does not exist|not found on the relay/.test( + message, + ) + ) { + return "missing"; + } + if ( + /remote branch .* not found|could not resolve the requested repository ref|couldn't find remote ref/.test( + message, + ) + ) { + return "ref"; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return "network"; + } + return "unknown"; +} diff --git a/desktop/src/features/projects/lib/projectRepoHost.ts b/desktop/src/features/projects/lib/projectRepoHost.ts new file mode 100644 index 000000000..07e27f922 --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoHost.ts @@ -0,0 +1,78 @@ +import { effectiveCloneUrls } from "./projectCloneUrl"; + +export type ProjectRepoHost = + | { kind: "buzz" } + | { kind: "external"; host: string } + | { kind: "unresolved" }; + +/** + * Classifies the canonical git remote using the same origin and path boundary + * enforced by the Tauri git commands. This is presentation/query gating only; + * Rust remains the security boundary for clone operations. + */ +export function projectRepoHost( + cloneUrl: string | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!cloneUrl || !relayOrigin) return { kind: "unresolved" }; + + try { + const clone = new URL(cloneUrl); + const relay = new URL(relayOrigin); + const isBuzzPath = /^\/git\/[0-9a-f]{64}\/[^/]+\/?$/i.test(clone.pathname); + + if (clone.origin === relay.origin && isBuzzPath) { + return { kind: "buzz" }; + } + + return { kind: "external", host: clone.host }; + } catch { + return { kind: "unresolved" }; + } +} + +type RepositoryHostInput = { + cloneUrls: string[]; + dtag: string; + owner: string; + repoAddress?: string; +}; + +export function projectRepoHostForRepository( + repository: RepositoryHostInput | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!repository) return { kind: "unresolved" }; + const cloneUrl = effectiveCloneUrls( + repository.cloneUrls, + relayOrigin, + repository.owner, + repository.dtag, + )[0]; + return projectRepoHost(cloneUrl, relayOrigin); +} + +export function projectRepoHostForProject( + project: + | RepositoryHostInput + | { + primaryRepositoryAddress: string | null; + repositories: RepositoryHostInput[]; + } + | null + | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!project) return { kind: "unresolved" }; + if (!("repositories" in project)) { + return projectRepoHostForRepository(project, relayOrigin); + } + + const repository = + project.repositories.find( + (candidate) => candidate.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null; + return projectRepoHostForRepository(repository, relayOrigin); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 032827144..6084c7275 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -2,16 +2,23 @@ import type { Project, ProjectActivitySummary, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; export type ProjectsViewMode = "grid" | "list"; -export type ProjectsRepositoryScope = "all" | "mine" | "local"; +export type ProjectsRepositoryScope = + | "all" + | "mine" + | "local" + | "buzz" + | "linked"; export type ProjectsWorkItemScope = "all" | "mine"; export type ProjectsFilter = | "all" | "mine" | "local" + | "projects" | "repositories" | "prs" | "issues" @@ -51,6 +58,7 @@ export function readStoredFilter(): ProjectsFilter { const value = globalThis.localStorage?.getItem(PROJECTS_FILTER_STORAGE_KEY); return value === "mine" || value === "local" || + value === "projects" || value === "repositories" || value === "prs" || value === "issues" || @@ -76,7 +84,14 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope { const value = globalThis.localStorage?.getItem( PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, ); - if (value === "mine" || value === "local") return value; + if ( + value === "mine" || + value === "local" || + value === "buzz" || + value === "linked" + ) { + return value; + } const legacyFilter = globalThis.localStorage?.getItem( PROJECTS_FILTER_STORAGE_KEY, ); @@ -244,7 +259,10 @@ export function projectPeople( ...new Set( [ project.owner, - ...project.contributors, + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), ...(summary?.participantPubkeys ?? []), ].map(normalizePubkey), ), @@ -269,7 +287,7 @@ export function normalizeRepositoryUrl(url: string) { } export function getClonePathLabel(project: Project) { - const cloneUrl = project.cloneUrls[0]; + const cloneUrl = selectProjectRepository(project, null)?.cloneUrls[0]; if (!cloneUrl) return "Clone path pending"; try { @@ -281,9 +299,7 @@ export function getClonePathLabel(project: Project) { } function repositoryIdentityKey(project: Project) { - const cloneUrl = project.cloneUrls[0]; - if (cloneUrl) return normalizeRepositoryUrl(cloneUrl); - return (project.name || project.dtag).trim().toLowerCase(); + return project.id; } export function uniqueRepositories(projects: Project[]) { @@ -325,8 +341,12 @@ export function isProjectMine( const normalizedCurrentPubkey = normalizePubkey(currentPubkey); return ( normalizePubkey(project.owner) === normalizedCurrentPubkey || - project.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + project.repositories.some( + (repository) => + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ), ) ); } diff --git a/desktop/src/features/projects/projectActivity.d.mts b/desktop/src/features/projects/projectActivity.d.mts index 7277e7bf7..ec09ba909 100644 --- a/desktop/src/features/projects/projectActivity.d.mts +++ b/desktop/src/features/projects/projectActivity.d.mts @@ -1,7 +1,7 @@ -import type { ProjectActivitySummary, Project } from "./hooks"; +import type { ProjectActivitySummary, Repository } from "./hooks"; import type { RelayEvent } from "@/shared/api/types"; export function summarizeProjectActivityEvents( events: RelayEvent[], - projects: Project[], + projects: Repository[], ): Record; diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs new file mode 100644 index 000000000..ed6e9328c --- /dev/null +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildInitialProjectEventTemplates, + isUnsupportedProjectKindError, +} from "./projectCreation.ts"; + +const OWNER = "a".repeat(64); +const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { + const templates = buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + cloneUrl: "https://relay.example/git/owner/sprout.git", + description: "A multi-repository workspace", + name: "Sprout", + ownerPubkey: OWNER, + webUrl: "https://example.com/sprout", + }); + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["a", `30617:${OWNER}:sprout`], + ]); + assert.equal(templates.project.content, ""); + assert.deepEqual(templates.repository.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["clone", "https://relay.example/git/owner/sprout.git"], + ["web", "https://example.com/sprout"], + ]); +}); + +test("buildInitialProjectEventTemplates rejects names without an identifier", () => { + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + name: "!!!", + ownerPubkey: OWNER, + }), + /letters or numbers/, + ); +}); + +test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { + assert.doesNotThrow(() => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(512), + name: "Sprout", + ownerPubkey: OWNER, + }), + ); + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(513), + name: "Sprout", + ownerPubkey: OWNER, + }), + /2,048 bytes/, + ); +}); + +test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { + assert.equal( + isUnsupportedProjectKindError( + new Error("restricted: unknown event kind 30621"), + ), + true, + ); + assert.equal( + isUnsupportedProjectKindError(new Error("mock project event rejection")), + false, + ); +}); diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts new file mode 100644 index 000000000..a42cf7bfd --- /dev/null +++ b/desktop/src/features/projects/projectCreation.ts @@ -0,0 +1,113 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { isValidProjectChannelId } from "./projectModels"; + +export type ProjectEventTemplate = { + kind: number; + content: string; + tags: string[][]; +}; + +export type InitialProjectEventTemplates = { + dtag: string; + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; +}; + +export function isUnsupportedProjectKindError(error: unknown): boolean { + return ( + error instanceof Error && + /(?:unknown|unsupported) event kind/i.test(error.message) + ); +} + +function projectDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function buildInitialProjectEventTemplates({ + accessChannelId, + cloneUrl, + description, + name, + ownerPubkey, + webUrl, +}: { + accessChannelId: string; + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + webUrl?: string; +}): InitialProjectEventTemplates { + const normalizedName = name.trim(); + if (!normalizedName) { + throw new Error("Project name is required."); + } + if (new TextEncoder().encode(normalizedName).byteLength > 256) { + throw new Error("Project name must not exceed 256 bytes."); + } + const dtag = projectDtagFromName(normalizedName); + if (!dtag) { + throw new Error("Project name must include letters or numbers."); + } + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalizedOwner)) { + throw new Error("Project owner public key is invalid."); + } + + const normalizedDescription = description?.trim() ?? ""; + if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { + throw new Error("Project description must not exceed 2,048 bytes."); + } + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const projectTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId.trim(); + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + projectTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + projectTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) { + repositoryTags.push(["clone", normalizedCloneUrl]); + } + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) { + repositoryTags.push(["web", normalizedWebUrl]); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + projectTags.push(["a", repositoryAddress]); + + return { + dtag, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: "", + tags: projectTags, + }, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + }; +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs new file mode 100644 index 000000000..5d60bd476 --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + enumerateProjectEvents, + buildProjectsFromFetcher, +} from "./projectEnumeration.ts"; + +function relayEvent(id, createdAt) { + return { + id: id.repeat(64), + kind: 30617, + pubkey: "a".repeat(64), + created_at: createdAt, + content: "", + tags: [["d", id]], + }; +} + +function fetcherFor(events) { + return async ({ limit, since, until }) => + events + .filter( + (event) => + (since === undefined || event.created_at >= since) && + (until === undefined || event.created_at <= until), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ) + .slice(0, limit); +} + +test("enumerateProjectEvents drains a tied boundary second before advancing", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 900), + relayEvent("c", 900), + relayEvent("d", 800), + ]; + + const result = await enumerateProjectEvents(fetcherFor(events), [30617], 3); + + assert.deepEqual( + result.map((event) => event.id).sort(), + events.map((event) => event.id).sort(), + ); +}); + +test("enumerateProjectEvents refuses to present a truncated boundary as complete", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 1_000), + relayEvent("c", 1_000), + ]; + + await assert.rejects( + enumerateProjectEvents(fetcherFor(events), [30617], 2), + /cannot exhaustively enumerate/, + ); +}); + +// ── Tombstone fetch-failure gate ───────────────────────────────────────────── +// +// `buildProjectsFromFetcher` (and therefore `fetchProjects`) must throw rather +// than returning a project list when the kind:5 tombstone enumeration rejects. +// A silent empty-set substitution would resurrect every deleted head served by +// a history-retaining relay. + +test("buildProjectsFromFetcher throws when kind-5 tombstone enumeration rejects", async () => { + const OWNER = "a".repeat(64); + const DTAG = "relay"; + + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["d", DTAG]], + }; + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", DTAG], + ["name", DTAG], + ], + }; + + // Fetcher: succeeds for project/repo kinds, rejects for kind:5 (tombstones). + const fetchExhaustively = async (kinds) => { + if (kinds.includes(5)) { + throw new Error("relay unavailable"); + } + if (kinds.includes(30621)) return [projectEvent]; + if (kinds.includes(30617)) return [repoEvent]; + return []; + }; + + await assert.rejects( + buildProjectsFromFetcher(fetchExhaustively), + /Could not fetch project deletion records/, + "tombstone fetch failure must propagate as a throw, not an empty deletion set", + ); +}); + +test("buildProjectsFromFetcher does not throw when tombstone enumeration succeeds with an empty result", async () => { + // Verify the happy path: no tombstones → returns projects without error. + const OWNER = "a".repeat(64); + + const fetchExhaustively = async (kinds) => { + if (kinds.includes(5)) return []; // no deletions + if (kinds.includes(30621)) return []; // no explicit projects + if (kinds.includes(30617)) + return [ + { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ], + }, + ]; + return []; + }; + + const projects = await buildProjectsFromFetcher(fetchExhaustively); + // Legacy project (unclaimed repo) should appear. + assert.ok(Array.isArray(projects), "must return a project array"); + assert.equal( + projects.some((p) => p.legacy), + true, + "unclaimed repo must appear as legacy project", + ); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts new file mode 100644 index 000000000..072b417a5 --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -0,0 +1,129 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_DELETION, + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { buildProjectReadModels, type Project } from "./projectModels"; + +const PROJECT_ENUMERATION_PAGE_SIZE = 500; + +type ProjectEventFilter = { + kinds: number[]; + limit: number; + since?: number; + until?: number; +}; + +type FetchProjectEventPage = ( + filter: ProjectEventFilter, +) => Promise; + +/** + * Enumerates a NIP-01 websocket filter with the boundary-bucket drain required + * by NIP-MP. A bare `until` cursor cannot safely advance until every event in + * the oldest returned second has been retrieved. + */ +export async function enumerateProjectEvents( + fetchPage: FetchProjectEventPage, + kinds: number[], + pageSize: number, +): Promise { + if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { + throw new Error( + "Project enumeration page size must be a positive integer.", + ); + } + + const eventsById = new Map(); + let until: number | undefined; + + for (;;) { + const page = await fetchPage({ + kinds, + limit: pageSize, + ...(until === undefined ? {} : { until }), + }); + for (const event of page) eventsById.set(event.id, event); + if (page.length < pageSize) return [...eventsById.values()]; + + const oldest = Math.min(...page.map((event) => event.created_at)); + const boundary = await fetchPage({ + kinds, + limit: pageSize, + since: oldest, + until: oldest, + }); + for (const event of boundary) eventsById.set(event.id, event); + if (boundary.length >= pageSize) { + // Invariant violation: the relay has more events sharing this exact + // second than the page limit. Enumeration is statically uncompletable + // at the current page size. Rather than present a silently truncated + // collection, we hard-error. If this surfaces in production, the fix is + // either a larger pageSize constant or a relay-side deduplication pass. + // TODO: add a telemetry event here so pathological relay states are + // diagnosable before they reach users. + throw new Error( + "The relay cannot exhaustively enumerate projects because too many events share one timestamp.", + ); + } + if (oldest <= 0) return [...eventsById.values()]; + until = oldest - 1; + } +} + +export function fetchProjectEventsExhaustively( + kinds: number[], + pageSize = PROJECT_ENUMERATION_PAGE_SIZE, +): Promise { + return enumerateProjectEvents( + (filter) => relayClient.fetchEvents(filter), + kinds, + pageSize, + ); +} + +/** + * Core fetch-and-build logic for `fetchProjects`, extracted for testability. + * + * Accepts an injectable `fetchExhaustively` so unit tests can stub individual + * kind enumerations (including injecting a rejection for kind:5 tombstones) without + * pulling in the Tauri relay client. + * + * Fail-closed: if the kind:5 tombstone enumeration rejects, throws rather than + * returning an empty deletion set that would resurrect every deleted head. + */ +export async function buildProjectsFromFetcher( + fetchExhaustively: (kinds: number[]) => Promise, + options: { + relayOrigin?: string | null; + hiddenAddresses?: ReadonlySet; + } = {}, +): Promise { + const [projectEvents, repositoryEvents, tombstoneResult] = await Promise.all([ + fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), + fetchExhaustively([KIND_DELETION]).then( + (events) => ({ ok: true as const, events }), + (error: unknown) => ({ + ok: false as const, + message: error instanceof Error ? error.message : "Unknown error", + }), + ), + ]); + + if (!tombstoneResult.ok) { + throw new Error( + `Could not fetch project deletion records: ${tombstoneResult.message} — refresh to retry.`, + ); + } + + return buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents: tombstoneResult.events, + relayOrigin: options.relayOrigin ?? null, + hiddenAddresses: options.hiddenAddresses ?? new Set(), + }).sort((a, b) => b.createdAt - a.createdAt); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b31dc3c1f..4b0420602 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -24,6 +24,8 @@ export type ProjectIssue = { author: string; createdAt: number; repoAddress: string | null; + channelId: string | null; + originAgentName: string | null; labels: string[]; recipients: string[]; status: ProjectIssueStatus; @@ -54,6 +56,11 @@ export function projectIssueEventsToIssues( statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], ): ProjectIssue[]; +export function nextProjectIssueCommentCreatedAt( + issue: ProjectIssue, + now: number, + author: string, +): number; export function buildGitIssueTags(input: { repoAddress: string; repoOwner: string; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 065524586..331837ac5 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -110,6 +110,8 @@ export function eventToProjectIssue( author: issue.pubkey, createdAt: issue.created_at, repoAddress: getTag(issue, "a") ?? null, + channelId: getTag(issue, "h") ?? null, + originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), status: statusFromEvent(issue, latestStatus), @@ -134,6 +136,17 @@ export function projectIssueEventsToIssues( .sort((left, right) => right.updatedAt - left.updatedAt); } +/** Keep consecutive comments ordered across whole-second Nostr timestamps. */ +export function nextProjectIssueCommentCreatedAt(issue, now, author) { + const normalizedAuthor = author.toLowerCase(); + return Math.max( + now, + ...issue.comments + .filter((comment) => comment.author.toLowerCase() === normalizedAuthor) + .map((comment) => comment.createdAt + 1), + ); +} + export function buildGitIssueTags({ repoAddress, repoOwner, diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 2d0fb5fb4..327541214 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,7 @@ import { eventToProjectIssue, getAllTags, getTag, + nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -125,6 +126,31 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); +test("parses public and private-safe issue provenance", () => { + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const publicIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["h", channelId], + ], + }), + ); + const privateIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["buzz-origin-agent", "Builder"], + ], + }), + ); + + assert.equal(publicIssue.channelId, channelId); + assert.equal(publicIssue.originAgentName, null); + assert.equal(privateIssue.channelId, null); + assert.equal(privateIssue.originAgentName, "Builder"); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ @@ -139,3 +165,39 @@ test("builds repository-scoped issue creation tags", () => { ], ); }); + +test("orders consecutive issue comments across whole-second timestamps", () => { + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + { + id: "comment-1", + kind: 1, + pubkey: AUTHOR, + created_at: 200, + content: "First", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "comment-2", + kind: 1, + pubkey: AUTHOR, + created_at: 201, + content: "Second", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "attacker-comment", + kind: 1, + pubkey: ATTACKER, + created_at: 10_000, + content: "Future", + tags: [["e", "e".repeat(64), "", "root"]], + }, + ], + ); + + assert.equal(nextProjectIssueCommentCreatedAt(issue, 200, AUTHOR), 202); + assert.equal(nextProjectIssueCommentCreatedAt(issue, 300, AUTHOR), 300); +}); diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs new file mode 100644 index 000000000..7be105584 --- /dev/null +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -0,0 +1,704 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + addRepositoryToProject, + buildProjectReadModels, + eventToExplicitProject, + eventToRepository, + selectProjectRepository, +} from "./projectModels.ts"; +import { projectMatchesRouteId } from "./projectRoutes.ts"; + +const PROJECT_OWNER = "a".repeat(64); +const FRONTEND_OWNER = "b".repeat(64); +const BACKEND_OWNER = "c".repeat(64); +const RELAY_ORIGIN = "https://relay.example"; + +function repositoryEvent(owner, id, createdAt = 100) { + return { + id: `${id}-${createdAt}`, + kind: 30617, + pubkey: owner, + created_at: createdAt, + content: "", + tags: [ + ["d", id], + ["name", id], + ], + }; +} + +function projectEvent(repositoryTags, overrides = {}) { + return { + id: "project-event", + kind: 30621, + pubkey: PROJECT_OWNER, + created_at: 200, + content: "ignored by NIP-MP readers", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["description", "A multi-repository project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ...repositoryTags, + ], + ...overrides, + }; +} + +test("eventToRepository preserves repository-scoped identity and clone data", () => { + const repository = eventToRepository( + repositoryEvent(FRONTEND_OWNER, "frontend"), + RELAY_ORIGIN, + ); + + assert.equal(repository.id, `${FRONTEND_OWNER}:frontend`); + assert.equal(repository.repoAddress, `30617:${FRONTEND_OWNER}:frontend`); + assert.deepEqual(repository.cloneUrls, [ + `${RELAY_ORIGIN}/git/${FRONTEND_OWNER}/frontend`, + ]); +}); + +test("buildProjectReadModels resolves repositories with a deterministic selection fallback", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const backendAddress = `30617:${PROJECT_OWNER}:backend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", backendAddress, "wss://relay.example"], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0].id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].projectAddress, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].primaryRepositoryAddress, backendAddress); + assert.deepEqual( + projects[0].repositories.map((repository) => repository.repoAddress), + [backendAddress, frontendAddress], + ); + assert.equal( + projects[0].repositoryRelayHints[backendAddress], + "wss://relay.example", + ); +}); + +test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects[0].legacy, false); + assert.equal(projects[1].legacy, true); + assert.equal( + projects[1].primaryRepositoryAddress, + projects[1].projectAddress, + ); + assert.equal(projects[1].repositories[0].dtag, "backend"); +}); + +test("buildProjectReadModels does not let an unauthorized project hide a repository", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [repositoryEvent(FRONTEND_OWNER, "frontend")], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects.filter((project) => project.legacy).length, 1); + assert.equal(projects.filter((project) => !project.legacy).length, 1); +}); + +test("project and repository routes stay distinct when coordinates share a d tag", () => { + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([])], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const explicitProject = projects.find((project) => !project.legacy); + const implicitProject = projects.find((project) => project.legacy); + + assert.notEqual(explicitProject.id, implicitProject.id); + assert.equal( + projectMatchesRouteId(explicitProject, explicitProject.projectAddress), + true, + ); + assert.equal( + projectMatchesRouteId(explicitProject, implicitProject.projectAddress), + false, + ); +}); + +test("addRepositoryToProject promotes a legacy repository route to a project coordinate", () => { + const [legacyProject] = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const attachedRepository = eventToRepository( + repositoryEvent(PROJECT_OWNER, "mobile"), + RELAY_ORIGIN, + ); + const updated = addRepositoryToProject( + legacyProject, + attachedRepository, + 300, + ); + + assert.equal(updated.id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(updated.legacy, false); + assert.equal(updated.repositories.length, 2); +}); + +test("selectProjectRepository honors a request and falls back to primary", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", `30617:${PROJECT_OWNER}:backend`], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal( + selectProjectRepository(projects[0], `${PROJECT_OWNER}:backend`)?.dtag, + "backend", + ); + assert.equal( + selectProjectRepository(projects[0], "missing:repository")?.dtag, + "backend", + ); + assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); +}); + +function coordinateParts(coordinate) { + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + return { + kind: Number(coordinate.slice(0, first)), + owner: coordinate.slice(first + 1, second), + dtag: coordinate.slice(second + 1), + }; +} + +function sortedJson(values) { + return values + .map((value) => JSON.stringify(value)) + .sort() + .map((value) => JSON.parse(value)); +} + +test("buildProjectReadModels conforms to the shared NIP-MP fold fixtures", () => { + const fixture = JSON.parse( + readFileSync( + new URL( + "../../../../docs/nips/NIP-MP.fold-fixtures.json", + import.meta.url, + ), + "utf8", + ), + ); + + for (const [caseIndex, fixtureCase] of fixture.cases.entries()) { + let eventIndex = caseIndex * 100; + const hiddenAddresses = new Set(); + const repositoryEvents = fixtureCase.repositories.flatMap((repository) => { + if (repository.viewer_hidden) hiddenAddresses.add(repository.coordinate); + if (repository.state !== "live") return []; + const { dtag, owner } = coordinateParts(repository.coordinate); + return [ + { + ...repositoryEvent( + owner, + dtag, + repository.created_at ?? 1_000 - caseIndex, + ), + id: (++eventIndex).toString(16).padStart(64, "0"), + tags: [ + ["d", dtag], + ["name", dtag], + ...(repository.maintainers?.length + ? [["maintainers", ...repository.maintainers]] + : []), + ], + }, + ]; + }); + const projectEvents = fixtureCase.projects.flatMap((project) => { + if (project.viewer_hidden) hiddenAddresses.add(project.coordinate); + if (project.state !== "live") return []; + const { dtag, owner } = coordinateParts(project.coordinate); + return [ + { + id: (++eventIndex).toString(16).padStart(64, "0"), + kind: 30621, + pubkey: owner, + created_at: project.created_at ?? 900 - caseIndex, + content: "", + tags: [ + ["d", dtag], + ["name", dtag], + ...(project.visibility === "unlisted" + ? [["buzz-visibility", "unlisted"]] + : []), + ...project.members.map((member) => ["a", member]), + ], + }, + ]; + }); + + const projects = buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: RELAY_ORIGIN, + hiddenAddresses, + }); + const actualContainers = projects + .filter((project) => !project.legacy) + .map((project) => ({ + project: project.projectAddress, + members: project.repositoryAddresses.flatMap((coordinate) => { + if ( + project.repositories.some( + (repository) => repository.repoAddress === coordinate, + ) + ) { + return [{ coordinate, render: "resolved" }]; + } + return project.unavailableRepositoryAddresses?.includes(coordinate) + ? [{ coordinate, render: "unavailable" }] + : []; + }), + })); + const expectedContainers = fixtureCase.expect.containers.map( + (container) => ({ + project: container.project, + members: sortedJson(container.members), + }), + ); + + assert.deepEqual( + sortedJson( + actualContainers.map((container) => ({ + ...container, + members: sortedJson(container.members), + })), + ), + sortedJson(expectedContainers), + fixtureCase.name, + ); + assert.deepEqual( + projects + .filter((project) => project.legacy) + .map((project) => project.projectAddress) + .sort(), + [...fixtureCase.expect.implicit_cards].sort(), + fixtureCase.name, + ); + } +}); + +// ── NIP-MP ingest-fixture conformance (TypeScript parser) ────────────────── +// +// The shared NIP-MP.fixtures.json is the ingest oracle used by the relay +// validator and the Rust CLI builder. The TypeScript read model must agree +// on every accept/reject case so that relay and desktop never diverge about +// which signed project heads are valid. + +test("buildProjectReadModels conforms to the shared NIP-MP ingest fixtures", () => { + const fixtures = JSON.parse( + readFileSync( + new URL("../../../../docs/nips/NIP-MP.fixtures.json", import.meta.url), + "utf8", + ), + ); + + const SIGNER = "a".repeat(64); + + for (const fixtureCase of fixtures.cases) { + const event = { + id: "e".repeat(64), + kind: fixtureCase.template.kind, + pubkey: SIGNER, + created_at: 1_000, + content: fixtureCase.template.content, + tags: fixtureCase.template.tags, + }; + + // Use eventToExplicitProject directly — buildProjectReadModels only returns + // listed projects, so an unlisted-but-valid envelope (e.g. valid_unlisted) + // would be filtered out before we could observe it. The ingest oracle only + // cares whether the envelope parses successfully, not whether it reaches + // the rendered collection; eventToExplicitProject is the correct gate. + const parsed = eventToExplicitProject(event, new Map(), new Map()); + const accepted = parsed !== null; + + if (fixtureCase.expect === "accept") { + assert.equal( + accepted, + true, + `${fixtureCase.name}: expected accept, got reject`, + ); + } else { + assert.equal( + accepted, + false, + `${fixtureCase.name}: expected reject, got accept`, + ); + } + } +}); + +// ── NIP-09 tombstone deletion ─────────────────────────────────────────────── + +test("buildProjectReadModels applies kind:5 tombstones to project events", () => { + const owner = "a".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["name", "Platform"], + ], + }; + const deletionEvent = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 200, + content: "", + tags: [["a", projectAddress]], + }; + + const withDeletion = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [deletionEvent], + }); + assert.equal( + withDeletion.filter((p) => !p.legacy).length, + 0, + "deleted project should not appear", + ); + + const withoutDeletion = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + }); + assert.equal( + withoutDeletion.filter((p) => !p.legacy).length, + 1, + "project without deletion should appear", + ); +}); + +test("buildProjectReadModels applies kind:5 tombstones to repository events", () => { + const owner = "b".repeat(64); + const repoAddress = `30617:${owner}:relay`; + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: owner, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ], + }; + const deletionEvent = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 200, + content: "", + tags: [["a", repoAddress]], + }; + + const withDeletion = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repoEvent], + deletionEvents: [deletionEvent], + }); + assert.equal(withDeletion.length, 0, "deleted repository should not appear"); +}); + +test("buildProjectReadModels ignores a tombstone that predates the live head", () => { + const owner = "a".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 200, + content: "", + tags: [["d", "platform"]], + }; + // Deletion is at t=100, but the live head is at t=200 — should not apply. + const staleDeletion = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 100, + content: "", + tags: [["a", projectAddress]], + }; + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [staleDeletion], + }); + assert.equal( + projects.filter((p) => !p.legacy).length, + 1, + "head newer than tombstone must survive", + ); +}); + +test("buildProjectReadModels rejects a tombstone signed by a different pubkey", () => { + const owner = "a".repeat(64); + const impostor = "b".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; + const foreignDeletion = { + id: "d".repeat(64), + kind: 5, + pubkey: impostor, + created_at: 200, + content: "", + tags: [["a", projectAddress]], + }; + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [foreignDeletion], + }); + assert.equal( + projects.filter((p) => !p.legacy).length, + 1, + "a stranger's tombstone must not delete someone else's project", + ); +}); + +// ── Route contract ────────────────────────────────────────────────────────── + +test("projectMatchesRouteId resolves a 30617 coordinate to the containing project", () => { + const projectOwner = "a".repeat(64); + const repoOwner = "c".repeat(64); + const repoAddress = `30617:${repoOwner}:relay`; + const projects = buildProjectReadModels({ + projectEvents: [ + { + id: "project-event", + kind: 30621, + pubkey: projectOwner, + created_at: 200, + content: "", + tags: [ + ["d", "platform"], + ["a", repoAddress], + ], + }, + ], + repositoryEvents: [ + { + id: "repo-event", + kind: 30617, + pubkey: repoOwner, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ["maintainers", projectOwner], + ], + }, + ], + }); + const explicitProject = projects.find((p) => !p.legacy); + assert.ok(explicitProject, "explicit project must be present"); + + // Entity link navigates with the 30617 coordinate (repo dtag ≠ project dtag). + assert.equal( + projectMatchesRouteId(explicitProject, repoAddress), + true, + "30617 route must resolve to the containing project", + ); + // Legacy project (implicit card for an unclaimed repo) must NOT match the + // explicit project's 30621 coordinate. + assert.equal( + projectMatchesRouteId(explicitProject, `30617:${projectOwner}:platform`), + false, + "unrelated 30617 address must not match", + ); +}); + +// ── 30617 route selects the correct repository (finding 4 regression gate) ── +// +// When `projectId` is a `30617::` coordinate (emitted by entity +// links in #4695), `selectProjectRepository` must resolve to the repository +// whose `id` is `:` — not to the project's primary repository. +// This test verifies the DI contract that `ProjectDetailScreen` uses to derive +// `routeRepositoryId` from `projectId`. + +test("selectProjectRepository resolves a non-primary repository when repositoryId is derived from a 30617 projectId", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + + const primaryRepo = { + id: `${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + repoAddress: `30617:${OWNER}:buzz`, + owner: OWNER, + cloneUrls: [], + webUrl: null, + description: "", + contributors: [], + createdAt: 100, + status: "active", + defaultBranch: "main", + }; + const nonPrimaryRepo = { + id: `${OTHER}:relay-tools`, + dtag: "relay-tools", + name: "Relay Tools", + repoAddress: `30617:${OTHER}:relay-tools`, + owner: OTHER, + cloneUrls: [], + webUrl: null, + description: "", + contributors: [], + createdAt: 80, + status: "active", + defaultBranch: "main", + }; + + const project = { + id: `30621:${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + description: "", + owner: OWNER, + createdAt: 100, + projectChannelId: null, + status: "active", + projectAddress: `30621:${OWNER}:buzz`, + primaryRepositoryAddress: primaryRepo.repoAddress, + repositoryAddresses: [primaryRepo.repoAddress, nonPrimaryRepo.repoAddress], + repositories: [primaryRepo, nonPrimaryRepo], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, + }; + + // Without a repositoryId: falls back to primary. + assert.equal( + selectProjectRepository(project, undefined)?.id, + primaryRepo.id, + "no repositoryId must yield the primary repository", + ); + + // With a repositoryId derived from the 30617 coordinate (as ProjectDetailScreen does): + // "30617::".slice("30617:".length) === ":" === Repository.id + const routeRepositoryId = nonPrimaryRepo.repoAddress.slice("30617:".length); + assert.equal( + routeRepositoryId, + nonPrimaryRepo.id, + "routeRepositoryId derivation must equal Repository.id", + ); + assert.equal( + selectProjectRepository(project, routeRepositoryId)?.id, + nonPrimaryRepo.id, + "derived repositoryId from 30617 coordinate must resolve to non-primary repository", + ); + assert.notEqual( + selectProjectRepository(project, routeRepositoryId)?.id, + primaryRepo.id, + "non-primary linked repo must NOT fall back to primary", + ); +}); + +// ── Tombstone exhaustive enumeration gate (finding 3) ─────────────────────── +// +// Verifies that `buildProjectReadModels` applies ALL deletion events in the +// supplied array — not just the first 2000 — proving the exhaustive-enumeration +// path (via `fetchProjectEventsExhaustively` in `fetchProjects`) makes a +// difference. The prior code capped kind:5 at 2000; this test ensures deletion +// 2001+ is honoured when the exhaustive path is used. + +test("buildProjectReadModels applies deletion beyond the 2000-event boundary", () => { + const OWNER = "a".repeat(64); + + // The project we want to verify is deleted (it's beyond event 2000). + const targetDtag = "beyond-limit"; + const targetAddress = `30621:${OWNER}:${targetDtag}`; + + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", targetDtag]], + }; + + // Build 2001 deletion events; the target is the last one. + const deletionEvents = Array.from({ length: 2001 }, (_, i) => ({ + id: String(i).padStart(64, "0"), + kind: 5, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["a", i < 2000 ? `30621:${OWNER}:filler-${i}` : targetAddress]], + })); + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents, + relayOrigin: null, + }); + + assert.equal( + projects.length, + 0, + "project at deletion event 2001+ must be suppressed when all tombstones are applied", + ); +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts new file mode 100644 index 000000000..6d539b54d --- /dev/null +++ b/desktop/src/features/projects/projectModels.ts @@ -0,0 +1,550 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { effectiveCloneUrls } from "./lib/projectCloneUrl"; + +export type Repository = { + id: string; + dtag: string; + name: string; + description: string; + cloneUrls: string[]; + webUrl: string | null; + owner: string; + contributors: string[]; + createdAt: number; + status: string; + defaultBranch: string; + repoAddress: string; + maintainers?: string[]; + channelId?: string | null; + eventContent?: string; + eventTags?: string[][]; +}; + +export type Project = { + id: string; + dtag: string; + name: string; + description: string; + owner: string; + createdAt: number; + projectChannelId: string | null; + status: string; + projectAddress: string; + primaryRepositoryAddress: string | null; + repositoryAddresses: string[]; + repositoryRelayHints?: Record; + repositories: Repository[]; + unavailableRepositoryAddresses?: string[]; + visibility?: "listed" | "unlisted"; + legacy: boolean; +}; + +type BuildProjectReadModelsInput = { + projectEvents: RelayEvent[]; + repositoryEvents: RelayEvent[]; + /** NIP-09 kind:5 deletion events relevant to projects and repositories. */ + deletionEvents?: RelayEvent[]; + relayOrigin?: string | null; + hiddenAddresses?: ReadonlySet; +}; + +const MAX_D_TAG_BYTES = 1_024; + +function getTag(event: RelayEvent, name: string): string | undefined { + const value = event.tags.find((tag) => tag[0] === name)?.[1]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function getAllTags(event: RelayEvent, name: string): string[] { + return event.tags + .filter( + (tag) => + tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + ) + .map((tag) => tag[1]); +} + +function getAllTagValues(event: RelayEvent, name: string): string[] { + return event.tags + .filter((tag) => tag[0] === name) + .flatMap((tag) => tag.slice(1)) + .filter((value) => value.length > 0); +} + +function getCloneUrls(event: RelayEvent): string[] { + const tag = event.tags.find((candidate) => candidate[0] === "clone"); + return tag?.slice(1).filter((value) => value.length > 0) ?? []; +} + +function isValidDTag(value: string): boolean { + return ( + value.length > 0 && + new TextEncoder().encode(value).byteLength <= MAX_D_TAG_BYTES + ); +} + +function isValidPubkey(value: string): boolean { + return /^[a-fA-F0-9]{64}$/.test(value); +} + +/** + * Validates a pubkey as a lowercase-only 64-hex string, per NIP-MP rule + * `member-coordinate-malformed`: owner hex MUST be lowercase so that `#a` + * filter matching (which is byte-exact) can resolve the coordinate. + */ +function isValidProjectMemberOwner(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value); +} + +/** NIP-MP rule `member-cap`: a project may carry at most 64 member `a` tags. */ +export const MAX_PROJECT_MEMBERS = 64; + +export function isValidProjectChannelId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); +} + +const SINGLETON_METADATA_TAGS = [ + "name", + "description", + "buzz-channel", + "buzz-visibility", +] as const; + +const MAX_METADATA_TAG_BYTES: Record = { + name: 256, + description: 2_048, + "buzz-channel": 256, + "buzz-visibility": 256, +}; + +/** + * Validates the NIP-MP tag/content envelope for a kind:30621 project event. + * Shared by both the read parser (`eventToExplicitProject`) and the write + * helper (`buildProjectPatchTemplate`) so Desktop's input and output agree on + * which heads are valid. + * + * Throws a descriptive error on the first violation found. + */ +export function validateProjectEventEnvelope( + tags: string[][], + content: string, +): void { + // NIP-MP rule `d-cardinality`: exactly one `d` tag is required. + const dTags = tags.filter((tag) => tag[0] === "d"); + if (dTags.length !== 1 || !dTags[0][1]) { + throw new Error( + `NIP-MP: expected exactly one non-empty 'd' tag, found ${dTags.length}.`, + ); + } + const dtag = dTags[0][1]; + if (!isValidDTag(dtag)) { + throw new Error(`NIP-MP: 'd' tag value exceeds the maximum byte length.`); + } + + // NIP-MP rule `metadata-cardinality`: at most one each of the singleton tags. + const encoder = new TextEncoder(); + for (const tagName of SINGLETON_METADATA_TAGS) { + const count = tags.filter((tag) => tag[0] === tagName).length; + if (count > 1) { + throw new Error( + `NIP-MP: duplicate '${tagName}' tag — at most one is permitted.`, + ); + } + } + + // NIP-MP rule `metadata-length`: per-field byte caps. + for (const [tagName, maxBytes] of Object.entries(MAX_METADATA_TAG_BYTES)) { + const value = tags.find((tag) => tag[0] === tagName)?.[1]; + if (value !== undefined && encoder.encode(value).byteLength > maxBytes) { + throw new Error( + `NIP-MP: '${tagName}' tag value exceeds the ${maxBytes}-byte limit.`, + ); + } + } + + // NIP-MP rule `member-cap`: at most 64 `a` membership tags. + const memberTags = tags.filter((tag) => tag[0] === "a"); + if (memberTags.length > MAX_PROJECT_MEMBERS) { + throw new Error( + `NIP-MP: project exceeds the ${MAX_PROJECT_MEMBERS}-member limit.`, + ); + } + + // NIP-MP rule `member-coordinate-malformed` + `member-arity`: + // each `a` tag must be a valid repository coordinate with a lowercase owner, + // and must carry 2 or 3 elements (address + optional relay hint). + const seenAddresses = new Set(); + for (const tag of memberTags) { + const address = tag[1]; + if (!address) { + throw new Error("NIP-MP: 'a' tag is missing a repository address."); + } + if (tag.length !== 2 && tag.length !== 3) { + throw new Error( + `NIP-MP: 'a' tag for '${address}' must have 2 or 3 elements.`, + ); + } + const parsed = parseRepositoryAddress(address); + if (!parsed) { + throw new Error( + `NIP-MP: invalid repository address '${address}' — expected '30617::'.`, + ); + } + if (seenAddresses.has(address)) { + throw new Error(`NIP-MP: duplicate repository address '${address}'.`); + } + seenAddresses.add(address); + } + + void content; // content is preserved verbatim; no constraint in NIP-MP. +} + +function deduplicateAddressableEvents(events: RelayEvent[]): RelayEvent[] { + const latest = new Map(); + for (const event of events) { + const dtag = getTag(event, "d"); + if (!dtag) continue; + const key = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const current = latest.get(key); + if ( + !current || + event.created_at > current.created_at || + (event.created_at === current.created_at && event.id < current.id) + ) { + latest.set(key, event); + } + } + return [...latest.values()]; +} + +function parseRepositoryAddress( + value: string, +): { owner: string; dtag: string } | null { + const firstSeparator = value.indexOf(":"); + const secondSeparator = value.indexOf(":", firstSeparator + 1); + if ( + value.slice(0, firstSeparator) !== String(KIND_REPO_ANNOUNCEMENT) || + secondSeparator < 0 + ) { + return null; + } + + const owner = value.slice(firstSeparator + 1, secondSeparator); + const dtag = value.slice(secondSeparator + 1); + return isValidProjectMemberOwner(owner) && isValidDTag(dtag) + ? { owner, dtag } + : null; +} + +export function eventToRepository( + event: RelayEvent, + relayOrigin?: string | null, +): Repository | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_REPO_ANNOUNCEMENT || + !dtag || + !isValidDTag(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const owner = event.pubkey.toLowerCase(); + const setupUsers = getAllTags(event, "auth"); + const channel = getTag(event, "buzz-channel"); + return { + id: `${owner}:${dtag}`, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? event.content ?? "", + cloneUrls: effectiveCloneUrls( + getCloneUrls(event), + relayOrigin, + owner, + dtag, + ), + webUrl: getTag(event, "web") ?? null, + owner, + contributors: [...new Set([...getAllTags(event, "p"), ...setupUsers])], + createdAt: event.created_at, + status: getTag(event, "status") ?? "active", + defaultBranch: getTag(event, "default-branch") ?? "main", + repoAddress: `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`, + channelId: channel && isValidProjectChannelId(channel) ? channel : null, + eventContent: event.content, + eventTags: event.tags.map((tag) => [...tag]), + maintainers: getAllTagValues(event, "maintainers") + .map((maintainer) => maintainer.toLowerCase()) + .filter(isValidPubkey), + }; +} + +export function eventToExplicitProject( + event: RelayEvent, + repositoriesByAddress: ReadonlyMap, + visibleRepositoriesByAddress: ReadonlyMap, +): Project | null { + if ( + event.kind !== KIND_PROJECT_ANNOUNCEMENT || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + // Delegate all NIP-MP envelope validation to the shared validator so the + // read parser and the write helper (`buildProjectPatchTemplate`) agree on + // which heads are valid. The parser rejects invalid events silently (returns + // null) while the write helper throws, so wrap in a try/catch here. + try { + validateProjectEventEnvelope(event.tags, event.content); + } catch { + return null; + } + + // After validation we know: exactly one `d` tag with a valid value, at most + // 64 `a` tags with valid repo coordinates, no duplicate addresses, and all + // singleton metadata tags within their byte caps. + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1] ?? ""; + const membershipTags = event.tags.filter((tag) => tag[0] === "a"); + const repositoryAddresses: string[] = []; + const repositoryRelayHints: Record = {}; + for (const membershipTag of membershipTags) { + const repositoryAddress = membershipTag[1]; + repositoryAddresses.push(repositoryAddress); + if (membershipTag[2]) { + repositoryRelayHints[repositoryAddress] = membershipTag[2]; + } + } + repositoryAddresses.sort(); + const primaryRepositoryAddress = + repositoryAddresses.find( + (address) => visibleRepositoriesByAddress.get(address)?.dtag === dtag, + ) ?? + repositoryAddresses.find((address) => + visibleRepositoriesByAddress.has(address), + ) ?? + null; + + const owner = event.pubkey.toLowerCase(); + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`; + + const rawVisibility = getTag(event, "buzz-visibility"); + const visibility = + rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); + const channel = getTag(event, "buzz-channel"); + return { + id: projectAddress, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? "", + owner, + createdAt: event.created_at, + projectChannelId: + channel && isValidProjectChannelId(channel) ? channel : null, + status: visibility === "listed" ? "active" : "unlisted", + projectAddress, + primaryRepositoryAddress, + repositoryAddresses, + repositoryRelayHints, + repositories: repositoryAddresses.flatMap((address) => { + const repository = visibleRepositoriesByAddress.get(address); + return repository ? [repository] : []; + }), + unavailableRepositoryAddresses: repositoryAddresses.filter( + (address) => !repositoriesByAddress.has(address), + ), + visibility, + legacy: false, + }; +} + +function repositoryToLegacyProject(repository: Repository): Project { + return { + id: repository.repoAddress, + dtag: repository.dtag, + name: repository.name, + description: repository.description, + owner: repository.owner, + createdAt: repository.createdAt, + projectChannelId: null, + status: repository.status, + projectAddress: repository.repoAddress, + primaryRepositoryAddress: repository.repoAddress, + repositoryAddresses: [repository.repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + }; +} + +/** + * Builds the set of addressable coordinates that have been authoritatively + * deleted per NIP-09 semantics: the deletion signer must equal the coordinate + * owner, and the deletion's `created_at` must be ≥ the live head's timestamp. + * Returns a `Map` for threshold comparison. + */ +function buildDeletionThresholds( + deletionEvents: RelayEvent[], +): Map { + const thresholds = new Map(); + for (const event of deletionEvents) { + const signer = event.pubkey.toLowerCase(); + for (const tag of event.tags) { + if (tag[0] !== "a" || !tag[1]) continue; + const coordinate = tag[1]; + // The signer must be the owner of the coordinate. + const firstColon = coordinate.indexOf(":"); + const secondColon = coordinate.indexOf(":", firstColon + 1); + if (firstColon < 0 || secondColon < 0) continue; + const owner = coordinate.slice(firstColon + 1, secondColon).toLowerCase(); + if (owner !== signer) continue; + // Keep the latest (most permissive) deletion threshold. + const existing = thresholds.get(coordinate); + if (existing === undefined || event.created_at > existing) { + thresholds.set(coordinate, event.created_at); + } + } + } + return thresholds; +} + +export function buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents = [], + relayOrigin, + hiddenAddresses = new Set(), +}: BuildProjectReadModelsInput): Project[] { + const deletionThresholds = buildDeletionThresholds(deletionEvents); + + /** Returns true when the event's addressable coordinate has been deleted. */ + function isDeleted(event: RelayEvent): boolean { + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!dtag) return false; + const coordinate = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const threshold = deletionThresholds.get(coordinate); + return threshold !== undefined && event.created_at <= threshold; + } + + const repositories = deduplicateAddressableEvents(repositoryEvents) + .filter((event) => !isDeleted(event)) + .flatMap((event) => { + const repository = eventToRepository(event, relayOrigin); + return repository ? [repository] : []; + }); + const repositoriesByAddress = new Map( + repositories.map((repository) => [repository.repoAddress, repository]), + ); + const visibleRepositories = repositories.filter( + (repository) => !hiddenAddresses.has(repository.repoAddress), + ); + const visibleRepositoriesByAddress = new Map( + visibleRepositories.map((repository) => [ + repository.repoAddress, + repository, + ]), + ); + + const explicitProjects = deduplicateAddressableEvents(projectEvents) + .filter((event) => !isDeleted(event)) + .flatMap((event) => { + const project = eventToExplicitProject( + event, + repositoriesByAddress, + visibleRepositoriesByAddress, + ); + return project && + project.visibility === "listed" && + !hiddenAddresses.has(project.projectAddress) + ? [project] + : []; + }); + const claimedRepositories = new Set( + explicitProjects.flatMap((project) => + project.repositoryAddresses.filter((address) => { + const repository = repositoriesByAddress.get(address); + return ( + repository && + (repository.owner === project.owner || + repository.maintainers?.includes(project.owner)) + ); + }), + ), + ); + const legacyProjects = visibleRepositories + .filter((repository) => !claimedRepositories.has(repository.repoAddress)) + .map(repositoryToLegacyProject); + + return [...explicitProjects, ...legacyProjects].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} + +export function selectProjectRepository( + project: Project | null | undefined, + requestedRepositoryId: string | null | undefined, +): Repository | null { + if (!project) return null; + + const requested = requestedRepositoryId + ? project.repositories.find( + (repository) => repository.id === requestedRepositoryId, + ) + : null; + if (requested) return requested; + + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} + +/** Returns the optimistic read model after adding a resolved repository. */ +export function addRepositoryToProject( + project: Project, + repository: Repository, + createdAt: number, +): Project { + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; + const repositoryAddresses = [ + ...new Set([...project.repositoryAddresses, repository.repoAddress]), + ].sort(); + const repositories = [ + ...project.repositories.filter( + (candidate) => candidate.repoAddress !== repository.repoAddress, + ), + repository, + ].sort((left, right) => left.repoAddress.localeCompare(right.repoAddress)); + + return { + ...project, + id: projectAddress, + createdAt, + legacy: false, + projectAddress, + primaryRepositoryAddress: + repositories.find((candidate) => candidate.dtag === project.dtag) + ?.repoAddress ?? + repositories[0]?.repoAddress ?? + null, + repositoryAddresses, + repositories, + unavailableRepositoryAddresses: + project.unavailableRepositoryAddresses?.filter( + (address) => address !== repository.repoAddress, + ) ?? [], + }; +} diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index af865d243..87e03f8d3 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -78,6 +78,8 @@ export type ProjectPullRequest = { repoAddress: string | null; /** Channel where the pull request originated (`h` tag), when provided. */ channelId: string | null; + /** Agent display name retained instead of a private conversation ID. */ + originAgentName: string | null; labels: string[]; recipients: string[]; /** Requested reviewers (root `p` tags + trusted review-request comments). */ diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 3eebaa74f..044f42181 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -364,6 +364,7 @@ export function eventToProjectPullRequest( createdAt: pullRequest.created_at, repoAddress: getTag(pullRequest, "a") ?? null, channelId: getTag(pullRequest, "h") ?? null, + originAgentName: getTag(pullRequest, "buzz-origin-agent") ?? null, labels: getAllTags(pullRequest, "t"), recipients: getAllTags(pullRequest, "p"), reviewers, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 937460481..75a987746 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -49,6 +49,15 @@ test("preserves an optional source channel from the pull request", () => { assert.equal(eventToProjectPullRequest(pullRequestEvent()).channelId, null); }); +test("preserves a private-safe agent origin without a channel ID", () => { + const event = pullRequestEvent(); + event.tags.push(["buzz-origin-agent", "Builder"]); + + const pullRequest = eventToProjectPullRequest(event); + assert.equal(pullRequest.channelId, null); + assert.equal(pullRequest.originAgentName, "Builder"); +}); + function updateEvent({ pubkey, createdAt, commit, cloneUrl }) { return { id: `update-${pubkey.slice(0, 8)}-${createdAt}`, diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs new file mode 100644 index 000000000..0a80e382c --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -0,0 +1,347 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildRepositoryChannelBindingTemplate, + buildProjectPatchTemplate, + buildAddedRepositoryEventTemplatesFromHead, +} from "./projectRepositoryCreation.ts"; +import { validateProjectEventEnvelope } from "./projectModels.ts"; + +const OWNER = "a".repeat(64); + +test("buildRepositoryChannelBindingTemplate preserves repository metadata", () => { + const repository = { + id: `${OWNER}:desktop`, + dtag: "desktop", + name: "Desktop", + description: "Desktop app", + owner: OWNER, + createdAt: 1, + repoAddress: `30617:${OWNER}:desktop`, + eventContent: "Desktop app", + eventTags: [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ], + }; + const template = buildRepositoryChannelBindingTemplate({ + channelId: "11111111-1111-4111-8111-111111111111", + ownerPubkey: OWNER, + repository, + }); + + assert.equal(template.content, "Desktop app"); + assert.deepEqual(template.tags, [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ]); +}); + +// ── buildProjectPatchTemplate: unknown-tag preservation ──────────────────── + +test("buildProjectPatchTemplate preserves unknown tags from the live head", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "ignored-by-nip-mp", + tags: [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Multi-repo project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["future-metadata", "preserve-me"], + ["alt", "extension tag that must survive round-trip"], + ["a", existingAddress], + ], + }; + const newAddress = `30617:${OWNER}:mobile`; + const template = buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: [existingAddress, newAddress], + }); + + // Content must be preserved verbatim (NIP-MP §content). + assert.equal(template.content, "ignored-by-nip-mp"); + + // Non-`a` tags — including unknown ones — must survive in order. + const nonMemberTags = template.tags.filter((t) => t[0] !== "a"); + assert.deepEqual(nonMemberTags, [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Multi-repo project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["future-metadata", "preserve-me"], + ["alt", "extension tag that must survive round-trip"], + ]); + + // New address must be added; sorted order maintained. + const memberTags = template.tags.filter((t) => t[0] === "a").map((t) => t[1]); + assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort()); +}); + +test("buildProjectPatchTemplate preserves relay hints on existing members", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + const existingWithHint = `30617:${OTHER}:relay`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["a", existingWithHint, "wss://relay.example"], + ], + }; + const newAddress = `30617:${OWNER}:mobile`; + const template = buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: [existingWithHint, newAddress], + }); + + const existingTag = template.tags.find( + (t) => t[0] === "a" && t[1] === existingWithHint, + ); + assert.ok(existingTag, "existing member tag must be present"); + assert.equal( + existingTag[2], + "wss://relay.example", + "relay hint must be preserved", + ); +}); + +test("buildProjectPatchTemplate rejects a non-owner caller", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OTHER, + repositoryAddresses: [], + }), + /Only the project owner/, + ); +}); + +test("buildProjectPatchTemplate rejects a repository address list exceeding 64 members", () => { + const OWNER = "a".repeat(64); + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "wide"]], + }; + const tooMany = Array.from( + { length: 65 }, + (_, i) => `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`, + ); + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: tooMany, + }), + /64/, + ); +}); + +// ── buildAddedRepositoryEventTemplatesFromHead: racing writer ─────────────── + +test("buildAddedRepositoryEventTemplatesFromHead detects a concurrent add via the live head", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const newDtag = "mobile"; + const newAddress = `30617:${OWNER}:${newDtag}`; + + // Live head already contains the address (another session snuck it in). + const liveHeadWithRace = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 150, + content: "", + tags: [ + ["d", "platform"], + ["a", existingAddress], + ["a", newAddress], // concurrent add + ], + }; + assert.throws( + () => + buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + existingRepositoryAddresses: [existingAddress], + liveHead: liveHeadWithRace, + name: "Mobile", + ownerPubkey: OWNER, + }), + /already contains.*mobile.*another session/, + ); +}); + +// ── validateProjectEventEnvelope: shared full-envelope validator ───────────── +// These tests pin the NIP-MP validation rules through the WRITE helper so that +// a nonconforming live head (e.g. from a permissive relay) is caught before +// Desktop signs and re-submits it. + +test("validateProjectEventEnvelope accepts a valid minimal envelope", () => { + assert.doesNotThrow(() => + validateProjectEventEnvelope([["d", "platform"]], ""), + ); +}); + +test("validateProjectEventEnvelope rejects missing d tag", () => { + assert.throws( + () => validateProjectEventEnvelope([["name", "X"]], ""), + /NIP-MP.*'d'/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate d tags", () => { + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "a"], + ["d", "b"], + ], + "", + ), + /NIP-MP.*'d'/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate name tags", () => { + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["name", "X"], + ["name", "Y"], + ], + "", + ), + /NIP-MP.*duplicate.*'name'/, + ); +}); + +test("validateProjectEventEnvelope rejects a name tag that exceeds 256 bytes", () => { + const longName = "x".repeat(257); + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["name", longName], + ], + "", + ), + /NIP-MP.*'name'.*256/, + ); +}); + +test("validateProjectEventEnvelope rejects a description tag that exceeds 2048 bytes", () => { + const longDesc = "x".repeat(2049); + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["description", longDesc], + ], + "", + ), + /NIP-MP.*'description'.*2048/, + ); +}); + +test("validateProjectEventEnvelope rejects more than 64 a-tags", () => { + const tags = [["d", "wide"]]; + for (let i = 0; i < 65; i++) { + tags.push([ + "a", + `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`, + ]); + } + assert.throws(() => validateProjectEventEnvelope(tags, ""), /NIP-MP.*64/); +}); + +test("validateProjectEventEnvelope rejects a member address with uppercase owner hex", () => { + const upperAddr = `30617:${"A".repeat(64)}:desktop`; + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["a", upperAddr], + ], + "", + ), + /NIP-MP.*invalid.*address/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate a-tag addresses", () => { + const addr = `30617:${"a".repeat(64)}:desktop`; + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["a", addr], + ["a", addr], + ], + "", + ), + /NIP-MP.*duplicate.*address/, + ); +}); + +test("buildProjectPatchTemplate catches duplicate d in live head via full-envelope validation", () => { + // A relay that accepted a nonconforming event could serve a head with two d tags. + // buildProjectPatchTemplate must catch this before signing. + const badLiveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["d", "extra"], + ], + }; + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead: badLiveHead, + ownerPubkey: OWNER, + repositoryAddresses: [], + }), + /NIP-MP.*'d'/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts new file mode 100644 index 000000000..832350f90 --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -0,0 +1,239 @@ +import type { RelayEvent } from "@/shared/api/types"; +import type { Repository } from "@/features/projects/hooks"; +import { + isValidProjectChannelId, + MAX_PROJECT_MEMBERS, + validateProjectEventEnvelope, +} from "@/features/projects/projectModels"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { ProjectEventTemplate } from "./projectCreation"; + +function repositoryDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Creates a project-replacement event template from a live, signed raw head + * (fetched immediately before the mutation). Only the `a` membership tags are + * patched; every other tag and the `content` field are preserved verbatim, + * satisfying NIP-MP's extension-tag preservation rule and preventing a cached + * UI projection from silently erasing unknown tags. + * + * Performs full NIP-MP envelope validation on the patched output via the shared + * `validateProjectEventEnvelope` validator — the same checks applied by the + * read parser — so Desktop's write path agrees with its read path on which + * heads are valid regardless of the relay in use. + */ +function buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses, +}: { + liveHead: RelayEvent; + ownerPubkey: string; + repositoryAddresses: string[]; +}): ProjectEventTemplate { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { + throw new Error("Only the project owner can add repositories."); + } + if (repositoryAddresses.length > MAX_PROJECT_MEMBERS) { + throw new Error( + `A project cannot contain more than ${MAX_PROJECT_MEMBERS} repositories.`, + ); + } + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + + // Replace all existing `a` tags with the new set, preserving everything else + // (d, name, description, buzz-channel, buzz-visibility, relay hints embedded + // in `a` tags, and any future/unknown tags). + const nonMemberTags = liveHead.tags.filter((tag) => tag[0] !== "a"); + const existingHints = new Map(); + for (const tag of liveHead.tags) { + if (tag[0] === "a" && tag[1] && tag[2]) { + existingHints.set(tag[1], tag[2]); + } + } + const memberTags = repositoryAddresses.sort().map((address): string[] => { + const hint = existingHints.get(address); + return hint ? ["a", address, hint] : ["a", address]; + }); + + const patchedTags = [...nonMemberTags, ...memberTags]; + const content = liveHead.content; + + // Validate the full patched envelope against NIP-MP rules. This catches + // nonconforming live heads (e.g., from a relay that accepted a malformed + // event) before we sign and re-submit, and pins the write path to the same + // spec the read parser enforces: duplicate `d`, duplicate/oversized + // metadata, malformed member arity, and the 64-member boundary. + validateProjectEventEnvelope(patchedTags, content); + + return { + kind: KIND_PROJECT_ANNOUNCEMENT, + content, + tags: patchedTags, + }; +} + +export { buildProjectPatchTemplate }; + +export function buildRepositoryChannelBindingTemplate({ + channelId, + ownerPubkey, + repository, +}: { + channelId: string; + ownerPubkey: string; + repository: Repository; +}): ProjectEventTemplate { + const normalizedChannelId = channelId.trim(); + if (ownerPubkey.trim().toLowerCase() !== repository.owner.toLowerCase()) { + throw new Error("Only the repository owner can repair its access."); + } + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Repository access channel is invalid."); + } + if (!repository.eventTags) { + throw new Error( + "Repository metadata is unavailable. Refresh and try again.", + ); + } + + return { + kind: KIND_REPO_ANNOUNCEMENT, + content: repository.eventContent ?? repository.description, + tags: [ + ...repository.eventTags + .filter((tag) => tag[0] !== "buzz-channel") + .map((tag) => [...tag]), + ["buzz-channel", normalizedChannelId], + ], + }; +} + +export type AddedRepositoryEventTemplatesFromHead = { + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; + repositoryDtag: string; +}; + +/** + * Builds the project-replacement + new-repository templates for `addRepo`, + * patching the live signed project head rather than reconstructing from the + * cached UI projection. This is the preferred path: it preserves unknown tags + * and detects concurrent writes before they cause data loss. + * + * The caller is responsible for checking that `liveHead.created_at` is not + * newer than the cached project's `createdAt` + the caller's margin (i.e., a + * dominated-write guard) before using the result. + */ +export function buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + cloneUrl, + description, + existingRepositoryAddresses, + liveHead, + name, + ownerPubkey, + webUrl, +}: { + accessChannelId?: string; + cloneUrl?: string; + description?: string; + existingRepositoryAddresses: string[]; + liveHead: RelayEvent; + name: string; + ownerPubkey: string; + webUrl?: string; +}): AddedRepositoryEventTemplatesFromHead { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + + const normalizedName = name.trim(); + if (!normalizedName) throw new Error("Repository name is required."); + const repositoryDtag = repositoryDtagFromName(normalizedName); + if (!repositoryDtag) { + throw new Error("Repository name must include letters or numbers."); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${repositoryDtag}`; + + // Read live membership from the fetched head (not the cached projection). + const liveAddresses = liveHead.tags + .filter((tag) => tag[0] === "a" && tag[1]) + .map((tag) => tag[1] as string); + + // If the repo is already in the live head (race: another session added it), + // surface that to the caller. + if (liveAddresses.includes(repositoryAddress)) { + throw new Error( + `This project already contains "${repositoryDtag}" (it was added by another session).`, + ); + } + + // An "unavailable member" is a coordinate already in the project's address + // list (cached projection) but absent from resolved repositories. When this + // happens we keep the existing addresses and add nothing new. + const isUnavailableMember = + existingRepositoryAddresses.includes(repositoryAddress); + + const normalizedDescription = description?.trim() ?? ""; + const repositoryTags: string[][] = [ + ["d", repositoryDtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId?.trim(); + if (!normalizedAccessChannelId) { + throw new Error( + "This project has no repository access channel to inherit.", + ); + } + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) repositoryTags.push(["clone", normalizedCloneUrl]); + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]); + + const newAddresses = isUnavailableMember + ? [...liveAddresses] + : [...liveAddresses, repositoryAddress]; + + const projectTemplate = buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses: newAddresses, + }); + + return { + project: projectTemplate, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + repositoryDtag, + }; +} diff --git a/desktop/src/features/projects/projectRoutes.ts b/desktop/src/features/projects/projectRoutes.ts new file mode 100644 index 000000000..2862c11d1 --- /dev/null +++ b/desktop/src/features/projects/projectRoutes.ts @@ -0,0 +1,73 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { Project } from "./projectModels"; + +function parseProjectRouteId(projectId: string): { + address: string | null; + owner: string | null; + dtag: string; +} { + const firstSeparator = projectId.indexOf(":"); + const secondSeparator = projectId.indexOf(":", firstSeparator + 1); + const kind = Number(projectId.slice(0, firstSeparator)); + if ( + secondSeparator > 0 && + (kind === KIND_PROJECT_ANNOUNCEMENT || kind === KIND_REPO_ANNOUNCEMENT) + ) { + const owner = projectId.slice(firstSeparator + 1, secondSeparator); + if (/^[0-9a-fA-F]{64}$/.test(owner)) { + const normalizedOwner = owner.toLowerCase(); + const dtag = projectId.slice(secondSeparator + 1); + return { + address: `${kind}:${normalizedOwner}:${dtag}`, + owner: normalizedOwner, + dtag, + }; + } + } + + const owner = projectId.slice(0, 64); + if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { + return { + address: null, + owner: owner.toLowerCase(), + dtag: projectId.slice(65), + }; + } + return { address: null, owner: null, dtag: projectId }; +} + +/** + * Matches a project against a route id. + * + * The route id may be: + * - A canonical project coordinate: `30621::` → exact address match. + * - A canonical repository coordinate: `30617::` → matches any + * project that contains that repository address, enabling entity links from + * PR/issue deep links to land on the correct project regardless of container + * placement. This is the contract Hayt's entity-link routing expects. + * - A legacy `:` form → dtag + owner match. + */ +export function projectMatchesRouteId( + project: Project, + projectId: string, +): boolean { + const { address, owner, dtag } = parseProjectRouteId(projectId); + + // Canonical 30617 coordinate: resolve to the project that contains this repo. + if ( + address && + Number(projectId.slice(0, projectId.indexOf(":"))) === + KIND_REPO_ANNOUNCEMENT + ) { + return project.repositoryAddresses.includes(address); + } + + return ( + (!address || project.projectAddress === address) && + project.dtag === dtag && + (!owner || project.owner.toLowerCase() === owner) + ); +} diff --git a/desktop/src/features/projects/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs new file mode 100644 index 000000000..21ee577b5 --- /dev/null +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; + +// ── Work-item deduplication ───────────────────────────────────────────────── +// +// NIP-MP §Multiple membership: a repository may belong to several projects. +// When it does, global issue/PR lists must produce exactly one row per work +// item — not one row per project membership. These tests call the exported +// production function with a stubbed fetchEvents to verify the dedup contract +// end-to-end, not just the filter algorithm in isolation. + +const REPO_OWNER = "a".repeat(64); +const REPO_DTAG = "relay"; +const REPO_ADDRESS = `30617:${REPO_OWNER}:${REPO_DTAG}`; + +const ISSUE_ID = "i".repeat(64); +const PR_ID = "p".repeat(64); +const PR_ID_2 = "q".repeat(64); + +// Two projects that both contain the same repository. +const projectA = { + repositories: [{ repoAddress: REPO_ADDRESS }], +}; +const projectB = { + repositories: [{ repoAddress: REPO_ADDRESS }], +}; + +// Minimal valid NIP-34 issue event for the shared repo. +function makeIssue(id, updatedAt = 100) { + return { + id, + kind: 1621, + pubkey: REPO_OWNER, + created_at: updatedAt, + content: "An issue", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Fix the thing"], + ], + }; +} + +// Minimal valid NIP-34 pull request event for the shared repo. +function makePR(id, updatedAt = 100) { + return { + id, + kind: 1618, // KIND_GIT_PULL_REQUEST + pubkey: REPO_OWNER, + created_at: updatedAt, + content: "A PR", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Add a feature"], + ], + }; +} + +// fetchEvents stub: returns the given root events (issues + PRs) and empty +// arrays for all other query kinds (updates, comments, statuses). +function makeFetchEvents(rootEvents) { + return async (filter) => { + const { kinds } = filter; + // Root issues (kind 1621) + PRs (kind 1618) + if (kinds?.includes(1621) || kinds?.includes(1618)) { + return rootEvents.filter((e) => kinds.includes(e.kind)); + } + // Everything else (PR updates, comments, statuses) — empty + return []; + }; +} + +test("fetchProjectsWorkItems deduplicates issues from a shared repository", async () => { + const issue = makeIssue(ISSUE_ID); + const fetchEvents = makeFetchEvents([issue]); + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + assert.equal( + result.issues.items.length, + 1, + "duplicate issue from shared repo must collapse to one row", + ); + assert.equal(result.issues.items[0].issue.id, ISSUE_ID); +}); + +test("fetchProjectsWorkItems deduplicates pull requests from a shared repository", async () => { + const pr1 = makePR(PR_ID, 100); + const pr2 = makePR(PR_ID_2, 90); + const fetchEvents = makeFetchEvents([pr1, pr2]); + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + assert.equal( + result.pullRequests.items.length, + 2, + "distinct PRs must survive dedup; only exact-id duplicates collapse", + ); + const ids = result.pullRequests.items.map((item) => item.pullRequest.id); + assert.ok(ids.includes(PR_ID), "first PR must be present"); + assert.ok(ids.includes(PR_ID_2), "second PR must be present"); +}); + +test("fetchProjectsWorkItems returns a single row for a PR present in both project contexts", async () => { + // Same PR id returned twice (once per project's relay query). + const pr = makePR(PR_ID, 100); + // The stub returns the same event for every root query, simulating + // the relay returning the same PR for both projects' repo addresses. + let callCount = 0; + const fetchEvents = async (filter) => { + if (filter.kinds?.includes(1618)) { + callCount += 1; + return [pr]; + } + return []; + }; + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + // The relay was queried once per unique repo address — but even if it + // returned the same id twice across the two project contexts, dedupe fires. + assert.equal( + result.pullRequests.items.length, + 1, + "same PR id appearing in both project contexts must produce one row", + ); + // Sanity: the stub was actually called (proves we ran the production path). + assert.ok(callCount >= 1, "fetchEvents must have been called"); +}); diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index a2f004770..c2170e687 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -20,10 +20,17 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; -type ProjectReference = { +type RepositoryReference = { repoAddress: string; }; +type ProjectReference = { + repositories: RepositoryReference[]; +}; + +type ProjectRepository = + TProject["repositories"][number]; + /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = | "comments" @@ -33,11 +40,19 @@ export type ProjectWorkItemSection = /** Aggregate work items plus any optional event groups that failed to load. */ export type ProjectsWorkItemsResult = { issues: { - items: Array<{ project: TProject; issue: ProjectIssue }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + issue: ProjectIssue; + }>; failedSections: ProjectWorkItemSection[]; }; pullRequests: { - items: Array<{ project: TProject; pullRequest: ProjectPullRequest }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + pullRequest: ProjectPullRequest; + }>; failedSections: ProjectWorkItemSection[]; }; }; @@ -54,31 +69,40 @@ function groupByRepoAddress(events: RelayEvent[]): Map { return grouped; } +type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0]; + /** Loads aggregate issue and pull-request data with bounded relay fan-out. */ export async function fetchProjectsWorkItems( projects: TProject[], + fetchEvents: ( + filter: FetchEventsInput, + ) => Promise = relayClient.fetchEvents.bind(relayClient), ): Promise> { const repoAddresses = [ - ...new Set(projects.map((project) => project.repoAddress)), + ...new Set( + projects.flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ), + ), ]; const [rootResult, updateResult, commentResult, statusResult] = await Promise.allSettled([ - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_GIT_PR_UPDATE], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_TEXT_NOTE], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [ KIND_GIT_STATUS_OPEN, KIND_GIT_STATUS_MERGED, @@ -109,27 +133,64 @@ export async function fetchProjectsWorkItems( const pullRequests = projects .flatMap((project) => - projectPullRequestEventsToPullRequests( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_PULL_REQUEST, - ), - updatesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - statusesByRepo.get(project.repoAddress) ?? [], - ).map((pullRequest) => ({ project, pullRequest })), + project.repositories.flatMap((repository) => + projectPullRequestEventsToPullRequests( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_PULL_REQUEST, + ), + updatesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + statusesByRepo.get(repository.repoAddress) ?? [], + ).map((pullRequest) => ({ project, pullRequest, repository })), + ), + ) + // Deduplicate by (repoAddress, pull-request id): a repository in N projects + // must produce exactly one aggregate row per pull request (NIP-MP §Multiple + // membership). First occurrence wins; that project's navigation context is + // kept for the row. + .filter( + (() => { + const seen = new Set(); + return (item: { + repository: RepositoryReference; + pullRequest: ProjectPullRequest; + }) => { + const key = `${item.repository.repoAddress}:${item.pullRequest.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }; + })(), ) .sort( (left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt, ); const issues = projects .flatMap((project) => - projectIssueEventsToIssues( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_ISSUE, - ), - statusesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - ).map((issue) => ({ project, issue })), + project.repositories.flatMap((repository) => + projectIssueEventsToIssues( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_ISSUE, + ), + statusesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + ).map((issue) => ({ issue, project, repository })), + ), + ) + // Deduplicate by (repoAddress, issue id). + .filter( + (() => { + const seen = new Set(); + return (item: { + repository: RepositoryReference; + issue: ProjectIssue; + }) => { + const key = `${item.repository.repoAddress}:${item.issue.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }; + })(), ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; diff --git a/desktop/src/features/projects/pullRequestMutations.ts b/desktop/src/features/projects/pullRequestMutations.ts index 4eae6464b..160c0a340 100644 --- a/desktop/src/features/projects/pullRequestMutations.ts +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -12,7 +12,7 @@ import { KIND_GIT_PULL_REQUEST, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { Project, ProjectPullRequest } from "./hooks"; +import type { ProjectPullRequest, Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs"; import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews"; diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index aa72c3164..ed6484385 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -14,7 +14,7 @@ import { KIND_GIT_STATUS_OPEN, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt, type ProjectPullRequest, diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 457ccca17..2a25f5d78 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -6,7 +6,11 @@ import { pullProjectLocalRepository, pushProjectLocalRepository, } from "@/shared/api/projectGit"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; +import { useProjectRepoHost } from "@/features/projects/useProjectRepoHost"; import { publishProjectPullRequestUpdate } from "./pullRequestMutations"; /** Local-vs-remote git sync status for a project checkout (ahead/behind @@ -21,9 +25,10 @@ export function useProjectRepoSyncStatusQuery( ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; const selectedBaseBranch = baseBranch ?? project?.defaultBranch ?? null; + const host = useProjectRepoHost(project); return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(host.kind === "buzz" && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", diff --git a/desktop/src/features/projects/repositoryActivityHooks.ts b/desktop/src/features/projects/repositoryActivityHooks.ts new file mode 100644 index 000000000..10733116a --- /dev/null +++ b/desktop/src/features/projects/repositoryActivityHooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { + fetchRepositoryActivitySummaries, + type Project, +} from "@/features/projects/hooks"; + +/** Fetches repository-specific activity for the repositories in these projects. */ +export function useRepositoryActivitySummariesQuery(projects: Project[]) { + const repositories = React.useMemo( + () => [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ], + [projects], + ); + const repoAddresses = React.useMemo( + () => repositories.map((repository) => repository.repoAddress).sort(), + [repositories], + ); + + return useQuery({ + enabled: repoAddresses.length > 0, + queryKey: ["projects", "activity-summaries", "repositories", repoAddresses], + queryFn: () => fetchRepositoryActivitySummaries(repositories), + staleTime: 30_000, + }); +} diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx new file mode 100644 index 000000000..609c38a2c --- /dev/null +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -0,0 +1,199 @@ +import * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import type { AddProjectRepositoryInput } from "@/features/projects/useAddProjectRepository"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +const FIELD_SHELL_CLASS = + "flex min-h-11 items-center rounded-xl border border-input bg-muted/40 px-3 transition-colors hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const FIELD_CONTROL_CLASS = + "h-8 border-0 bg-transparent px-0 py-0 text-muted-foreground/55 shadow-none outline-none ring-0 placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus-visible:ring-0"; + +export function AddProjectRepositoryDialog({ + accessChannelId, + channels, + isCreating, + onAdd, + onOpenChange, + open, + project, +}: { + accessChannelId?: string; + channels: Channel[]; + isCreating: boolean; + onAdd: (input: AddProjectRepositoryInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; +}) { + const [name, setName] = React.useState(""); + const [cloneUrl, setCloneUrl] = React.useState(""); + const [selectedChannelId, setSelectedChannelId] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const nameInputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + setName(""); + setCloneUrl(""); + setSelectedChannelId(accessChannelId ?? ""); + setErrorMessage(null); + const timerId = globalThis.setTimeout( + () => nameInputRef.current?.focus(), + 50, + ); + return () => globalThis.clearTimeout(timerId); + }, [accessChannelId, open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!name.trim() || !selectedChannelId) return; + setErrorMessage(null); + try { + await onAdd({ + accessChannelId: selectedChannelId, + cloneUrl: cloneUrl.trim() || undefined, + name: name.trim(), + project, + }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to add repository.", + ); + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + {isCreating ? "Adding..." : "Add repository"} + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Add repository" + > + void handleSubmit(event)} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder="mobile-app" + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+
+ +
+ +
+

+ Members of this channel can access the repository. +

+
+
+ +
+ { + setCloneUrl(event.target.value); + setErrorMessage(null); + }} + placeholder="https://relay.example.com/git/mobile-app.git" + spellCheck={false} + value={cloneUrl} + /> +
+
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx new file mode 100644 index 000000000..8c4c080c6 --- /dev/null +++ b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx @@ -0,0 +1,92 @@ +import { FolderGit2 } from "lucide-react"; +import * as React from "react"; + +import type { Project, Repository } from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +export function AttachProjectRepositoryDialog({ + isAttaching, + onAttach, + onOpenChange, + open, + project, + repositories, +}: { + isAttaching: boolean; + onAttach: (repository: Repository) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; + repositories: Repository[]; +}) { + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (open) setErrorMessage(null); + }, [open]); + + async function handleAttach(repository: Repository) { + setErrorMessage(null); + try { + await onAttach(repository); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to attach repository.", + ); + } + } + + return ( + { + if (!nextOpen && isAttaching) return; + onOpenChange(nextOpen); + }} + open={open} + > + +
+ {repositories.length === 0 ? ( +

+ Every available repository is already in this project. +

+ ) : ( + repositories.map((repository) => ( + + )) + )} + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index c5e6c2670..ff214d808 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -22,7 +23,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a new project (NIP-34 repo announcement). */ +/** Modal for publishing a project with its initial NIP-34 repository. */ export function CreateProjectDialog({ isCreating, onCreate, @@ -33,8 +34,20 @@ export function CreateProjectDialog({ const [description, setDescription] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [webUrl, setWebUrl] = React.useState(""); + const [accessChannelId, setAccessChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const channelsQuery = useChannelsQuery({ enabled: open }); + const accessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); React.useEffect(() => { if (!open) return; @@ -43,6 +56,7 @@ export function CreateProjectDialog({ setDescription(""); setCloneUrl(""); setWebUrl(""); + setAccessChannelId(accessChannels[0]?.id ?? ""); setErrorMessage(null); // Small delay to let the dialog animation start before focusing. @@ -50,18 +64,19 @@ export function CreateProjectDialog({ nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [open]); + }, [accessChannels, open]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName) return; + if (!trimmedName || !accessChannelId) return; setErrorMessage(null); try { await onCreate({ + accessChannelId, name: trimmedName, description: description.trim() || undefined, cloneUrl: cloneUrl.trim() || undefined, @@ -88,12 +103,14 @@ export function CreateProjectDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects are repositories published to this workspace's relay." + description="Projects group one or more repositories published to this workspace's relay." footer={
+
+ +
+ +
+

+ Members of this channel can access project repositories. +

+
+
- Web URL + Initial repository web URL Optional
void | Promise; + onCreated: ( + project: Project, + repository: Repository, + issueId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const createMutation = useCreateProjectIssueMutation(project); + const [repositoryId, setRepositoryId] = React.useState( + selectProjectRepository(initialProject, null)?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const createMutation = useCreateProjectIssueMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); async function handleCreate(input: CreateProjectWorkItemDialogInput) { - if (!project) throw new Error("Choose a repository."); + if (!project || !repository) throw new Error("Choose a repository."); const issueId = await createMutation.mutateAsync(input); toast.success("Issue created."); - await onCreated(project, issueId); + await onCreated(project, repository, issueId); } return ( @@ -66,12 +84,17 @@ export function CreateProjectIssueDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-issue-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx index d64d132ae..11ba74d8d 100644 --- a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx +++ b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx @@ -3,9 +3,11 @@ import { toast } from "sonner"; import { type Project, + type Repository, useProjectPullRequestsQuery, useRepoStateQuery, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useCreateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations"; import { useProjectRepoSyncStatusQuery } from "@/features/projects/repoSyncHooks"; @@ -25,61 +27,79 @@ export function CreatePullRequestDialog({ reposDir, }: { initialProjectId?: string; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; reposDir?: string | null; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); + const initialRepository = selectProjectRepository(initialProject, null); + const [repositoryId, setRepositoryId] = React.useState( + initialRepository?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); const initialSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, - project?.defaultBranch, + repository?.defaultBranch, ); const branchOptions = React.useMemo(() => { const names = [ - project?.defaultBranch, + repository?.defaultBranch, ...(repoStateQuery.data?.branches.map((branch) => branch.name) ?? []), initialSyncQuery.data?.localBranch, ].filter((name): name is string => Boolean(name)); return [...new Set(names)]; }, [ initialSyncQuery.data?.localBranch, - project?.defaultBranch, + repository?.defaultBranch, repoStateQuery.data?.branches, ]); const [targetBranch, setTargetBranch] = React.useState( - project?.defaultBranch ?? "", + repository?.defaultBranch ?? "", ); const [sourceBranch, setSourceBranch] = React.useState(""); const sourceSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, sourceBranch || null, targetBranch || null, ); - const createMutation = useCreateProjectPullRequestMutation(project); + const createMutation = useCreateProjectPullRequestMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); React.useEffect(() => { - if (!project) return; - setTargetBranch(project.defaultBranch); + if (!repository) return; + setTargetBranch(repository.defaultBranch); setSourceBranch(""); - }, [project]); + }, [repository]); React.useEffect(() => { if ( @@ -104,9 +124,9 @@ export function CreatePullRequestDialog({ (pullRequest) => (pullRequest.status === "Open" || pullRequest.status === "Draft") && pullRequest.branchName === sourceBranch && - (pullRequest.targetBranch ?? project?.defaultBranch) === targetBranch, + (pullRequest.targetBranch ?? repository?.defaultBranch) === targetBranch, ); - const selectionError = !project + const selectionError = !repository ? "Choose a repository." : !targetBranch ? "Choose a base branch." @@ -120,12 +140,12 @@ export function CreatePullRequestDialog({ ? "The compare branch must be pushed before opening a pull request." : null; const description = - project && sourceBranch && targetBranch - ? `${project.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` + repository && sourceBranch && targetBranch + ? `${repository.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` : "Choose a repository and branches to compare."; async function handleCreate(input: CreatePullRequestDialogInput) { - if (!project || !sourceCommit || selectionError) { + if (!project || !repository || !sourceCommit || selectionError) { throw new Error( selectionError ?? "Pull request branches are incomplete.", ); @@ -139,7 +159,7 @@ export function CreatePullRequestDialog({ reviewers: [], }); toast.success("Pull request created."); - await onCreated(project, pullRequestId); + await onCreated(project, repository, pullRequestId); } return ( @@ -165,12 +185,17 @@ export function CreatePullRequestDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-pull-request-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/GitHubMark.tsx b/desktop/src/features/projects/ui/GitHubMark.tsx new file mode 100644 index 000000000..29c960210 --- /dev/null +++ b/desktop/src/features/projects/ui/GitHubMark.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from "react"; + +export function GitHubMark(props: SVGProps) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx index 40757f152..c023f4dbb 100644 --- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx +++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx @@ -2,7 +2,10 @@ import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery"; import { useMergeProjectPullRequestMutation, diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx new file mode 100644 index 000000000..812629289 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -0,0 +1,72 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** Compact work-item author identity with a minimal hover summary. */ +export function ProjectAuthorIdentity({ + label, + profiles, + pubkey, + testId, +}: { + label: string; + profiles?: UserProfileLookup; + pubkey: string; + testId?: string; +}) { + const profile = profiles?.[normalizePubkey(pubkey)]; + const roleLabel = profile?.isAgent === true ? "Agent" : "Person"; + + return ( + + + + + + + + + + {label} + + {roleLabel} + + + + + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 4f4fef767..ed28ddd58 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -1,6 +1,7 @@ import { + CircleAlert, CircleDot, - FolderGit2, + Folders, GitCommit, GitPullRequest, TerminalSquare, @@ -22,6 +23,7 @@ import { getProjectUpdatedAt, relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -83,7 +85,7 @@ function ProjectUpdatedLabel({ ); } -function ProjectPeopleStack({ +export function ProjectPeopleStack({ pubkeys, profiles, workOwnerPubkey, @@ -100,7 +102,7 @@ function ProjectPeopleStack({ } return ( -
+
{visible.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); @@ -167,7 +169,7 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; -function ProjectStatsRow({ +export function ProjectStatsRow({ summary, fixedColumns = false, }: { @@ -207,7 +209,7 @@ function ProjectStatsRow({ // Segmented commits/PRs/issues distribution — the card's "progress bar". // Hovering thickens the bar and reveals a tooltip with the exact breakdown. -function ProjectActivityBar({ +export function ProjectActivityBar({ summary, }: { summary: ProjectActivitySummary | undefined; @@ -263,10 +265,58 @@ function StatusPill({ status }: { status: string }) { ); } +function RepositoryUnavailableIndicator({ + reason, +}: { + reason: ProjectRepoUnavailableReason | undefined; +}) { + if (!reason) return null; + const status = { + authentication: { + description: "Buzz could not authenticate with this repository.", + label: "Access failed", + }, + missing: { + description: "No git repository was found on the Buzz relay.", + label: "Uninitialized", + }, + network: { + description: "The Buzz git service could not be reached.", + label: "Unreachable", + }, + ref: { + description: "The advertised branch is missing from the git remote.", + label: "Branch missing", + }, + unknown: { + description: "Buzz could not load this repository.", + label: "Unavailable", + }, + }[reason]; + + return ( + + + + + + + +

{status.label}

+

{status.description}

+
+
+ ); +} + export function EmptyState() { return (
- +

No projects yet

@@ -280,7 +330,7 @@ export function EmptyState() { export function EmptyFilteredState() { return (

- +

No matching projects @@ -302,7 +352,7 @@ function ProjectCardButton({ }) { return ( + + {activeWorkItemCrumb ? ( + <> + + + + + + {activeWorkItemCrumb.title} + + + ) : activeTabCrumb ? ( + <> + + + + {activeTabCrumb} + + + ) : ( + + {project.name} + + )} + + {project.projectChannelId ? ( + + ) : null} +

+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11..1b2adf316 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,10 +1,4 @@ -import { - ArrowLeft, - ChevronRight, - ExternalLink, - FolderGit2, - MessageSquare, -} from "lucide-react"; +import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -12,6 +6,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type Project, + type Repository, useProjectQuery, useProjectIssuesQuery, useProjectLocalRepoDiffQuery, @@ -47,18 +42,13 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; -import { - channelChrome, - channelContentTopPaddingMeasurement, - topChromeInset, -} from "@/shared/layout/chromeLayout"; +import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; -import { cn } from "@/shared/lib/cn"; -import { isSafeUrl } from "@/shared/lib/url"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; import { useGitIdentityQuery } from "@/features/projects/useGitIdentity"; @@ -70,14 +60,21 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { selectProjectRepository } from "@/features/projects/projectModels"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; +import { showProjectCloneErrorToast } from "./projectGitErrorToast"; import { projectTerminalLabel, useOpenProjectTerminal, } from "./useOpenProjectTerminal"; import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories"; import { PROJECT_TAB_CRUMB_LABELS, projectPeople, @@ -90,6 +87,7 @@ type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -97,9 +95,15 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ "profileTab", "profileView", ] as const; +const PROJECT_REPOSITORY_SEARCH_KEYS = [ + "repositoryId", + "issueId", + "pullRequestId", + "commitHash", +] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId } = props; + const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -111,16 +115,34 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const projectQuery = useProjectQuery(projectId); const projectsQuery = useProjectsQuery(); const project = projectQuery.data; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); - const defaultBranch = project - ? resolveProjectDefaultBranch(project.defaultBranch, repoStateQuery.data) + // When the projectId is a canonical 30617:: coordinate (emitted by + // entity links in #4695), derive the repository selection directly from the + // : portion rather than falling back to the project's primary + // repository. Repository.id is ":", so stripping the kind+colon + // prefix gives the exact repository id. This ensures a linked PR/issue on a + // non-primary member opens from the correct repository instead of the primary. + const routeRepositoryId: string | undefined = React.useMemo(() => { + if (repositoryId) return repositoryId; + const kindStr = `${String(KIND_REPO_ANNOUNCEMENT)}:`; + if (!projectId.startsWith(kindStr)) return undefined; + // projectId is "30617::" — strip "30617:" to get ":" + return projectId.slice(kindStr.length); + }, [projectId, repositoryId]); + const repository = selectProjectRepository(project, routeRepositoryId); + const repoRemote = useProjectRepoPresentation(repository); + const { applyPatch: applyRepositorySearch } = useHistorySearchState( + PROJECT_REPOSITORY_SEARCH_KEYS, + ); + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) : null; const { branchOptions, forgetBranch, managedBranches, rememberBranch } = useOptimisticProjectBranches({ defaultBranch, observedBranches: repoStateQuery.data?.branches ?? [], - projectId, + projectId: repository?.id ?? projectId, referencedBranches: pullRequestsQuery.data?.map( (pullRequest) => pullRequest.branchName ?? null, @@ -130,7 +152,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { useProjectRepositoryRefSelection({ branchOptions, defaultBranch, - projectAvailable: Boolean(project), + projectAvailable: Boolean(repository), projectPending: projectQuery.isPending, tags: repoStateQuery.data?.tags ?? [], }); @@ -183,10 +205,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }, [], ); - const issuesQuery = useProjectIssuesQuery(project); + const issuesQuery = useProjectIssuesQuery(repository); const selectedBranchPullRequest = React.useMemo(() => { const projectRepositories = new Set( - (project?.cloneUrls ?? []).map(normalizeRepositoryUrl), + (repository?.cloneUrls ?? []).map(normalizeRepositoryUrl), ); const matches = pullRequestsQuery.data?.filter( @@ -197,7 +219,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ), ) ?? []; return matches.length === 1 ? matches[0] : null; - }, [activeBranch, project?.cloneUrls, pullRequestsQuery.data]); + }, [activeBranch, pullRequestsQuery.data, repository?.cloneUrls]); const openBranchPullRequest = selectedBranchPullRequest?.status === "Open" || selectedBranchPullRequest?.status === "Draft" @@ -210,58 +232,59 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { "remote", ); const repoSnapshotQuery = useProjectRepoSnapshotQuery( - project, + repository, activeBranch, selectedTag ? null : selectedBranchPullRequest, activeTag, + repoRemote.host.kind === "buzz", ); const repoDiffQuery = useProjectRepoDiffQuery( - project, + repository, activeBranch, activeRepoPullRequest, repoSource === "remote", ); const localRepoDiffQuery = useProjectLocalRepoDiffQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), ); const commitDiffQuery = useProjectCommitDiffQuery( - project, + repository, selectedCommitHash, repoSource, activeCommunity?.reposDir, ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const repoSyncStatusQuery = useProjectRepoSyncStatusQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const pushLocalRepoMutation = usePushProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, openBranchPullRequest, ); const pullLocalRepoMutation = usePullProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const cloneRepoMutation = useCloneProjectRepositoryMutation( - project, + repository, activeCommunity?.reposDir, ); - const createIssueMutation = useCreateProjectIssueMutation(project); + const createIssueMutation = useCreateProjectIssueMutation(repository); const updatePullRequestMutation = useUpdateProjectPullRequestMutation( - project, + repository, openBranchPullRequest, ); const hasLocalCheckout = Boolean( @@ -321,7 +344,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { defaultBranch, deleteBranchReason, forgetBranch, - project, + project: repository, refetchRepoState: repoStateQuery.refetch, rememberBranch, selectBranch: handleBranchChange, @@ -375,9 +398,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { : repoSyncStatusQuery.data?.localPath || localRepoSnapshotQuery.data ? "Local" : "Local missing", - remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote", + ...repoRemote.controls, onCloneLocal: - !selectedTag && project?.cloneUrls[0] + !selectedTag && repository?.cloneUrls[0] && repoRemote.canCloneLocally ? () => { void handleCloneRepo(); } @@ -421,7 +444,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }; const projectPending = projectQuery.isPending; React.useEffect(() => { - if (!project) { + if (!repository) { // While the project query is still loading, keep the URL-seeded // pullRequestId/issueId selections — clearing here would discard them // before the detail view ever gets a chance to open. @@ -430,7 +453,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedIssueId(null); setSelectedCommitHash(null); } - }, [project, projectPending]); + }, [projectPending, repository]); React.useEffect(() => { setRepoSource((currentSource) => { if (selectedTag) return "remote"; @@ -446,7 +469,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }); }, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]); const peoplePubkeys = React.useMemo(() => { - if (!project) return []; + if (!repository) return []; // Include PR authors/updaters so commit rows can resolve avatars for // publishers who are not listed as project contributors. const pullRequestPubkeys = (pullRequestsQuery.data ?? []).flatMap( @@ -465,12 +488,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); return [ ...new Set([ - ...projectPeople(project), + ...projectPeople(repository), ...pullRequestPubkeys, ...issuePubkeys, ]), ]; - }, [issuesQuery.data, project, pullRequestsQuery.data]); + }, [issuesQuery.data, pullRequestsQuery.data, repository]); const profilesQuery = useUsersBatchQuery(peoplePubkeys, { enabled: peoplePubkeys.length > 0, }); @@ -566,21 +589,36 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { toast.success(result.message); setRepoSource("local"); } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to clone repository", - ); + showProjectCloneErrorToast(error, repository?.cloneUrls[0]); } - }, [cloneRepoMutation]); + }, [cloneRepoMutation, repository?.cloneUrls]); const handlePullRequestCreated = React.useCallback( - async (createdProject: Project, pullRequestId: string) => { + async ( + createdProject: Project, + createdRepository: Repository, + pullRequestId: string, + ) => { if (createdProject.id !== projectId) { - await goProject(createdProject.id, { pullRequestId }); + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); return; } - await pullRequestsQuery.refetch(); + if (createdRepository.id === repository?.id) { + await pullRequestsQuery.refetch(); + } else { + applyRepositorySearch({ repositoryId: createdRepository.id }); + } setSelectedPullRequestId(pullRequestId); }, - [goProject, projectId, pullRequestsQuery], + [ + applyRepositorySearch, + goProject, + projectId, + pullRequestsQuery, + repository?.id, + ], ); const handleCreateIssue = React.useCallback( async ({ body, title }: CreateIssueDialogInput) => { @@ -640,12 +678,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback(() => { - if (!project) return Promise.resolve(); - return openTerminal(project, { + if (!repository) return Promise.resolve(); + return openTerminal(repository, { branch: activeBranch, hasLocalCheckout, }); - }, [activeBranch, hasLocalCheckout, openTerminal, project]); + }, [activeBranch, hasLocalCheckout, openTerminal, repository]); const handleOpenMergeRecoveryTerminal = React.useCallback( async (input: { expectedCommit: string; @@ -653,22 +691,22 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { sourceCloneUrl: string; targetBranch: string; }) => { - const targetCloneUrl = project?.cloneUrls[0]; - if (!project || !targetCloneUrl) { + const targetCloneUrl = repository?.cloneUrls[0]; + if (!repository || !targetCloneUrl) { throw new Error("No project selected."); } return openProjectMergeRecoveryTerminal({ ...input, - projectDtag: project.dtag, + projectDtag: repository.dtag, reposDir: activeCommunity?.reposDir, targetCloneUrl, }); }, - [activeCommunity?.reposDir, project], + [activeCommunity?.reposDir, repository], ); if (projectQuery.isLoading) { - return null; + return ; } if (projectQuery.isError) { return ( @@ -717,10 +755,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
); } + if (!repository) { + return ( +
+ +

{project.name}

+

+ This project does not have any available repositories yet. +

+ +
+ ); + } const repoContributors = repoSnapshotQuery.data?.contributors ?? []; - const safeWebUrl = - project.webUrl && isSafeUrl(project.webUrl) ? project.webUrl : null; const selectedPullRequest = pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ?? null; @@ -770,6 +818,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { // instead of whatever tab the work item left behind. setTabsResetKey((key) => key + 1); }; + const handleRepositoryChange = (nextRepositoryId: string) => { + applyRepositorySearch({ + repositoryId: nextRepositoryId, + issueId: null, + pullRequestId: null, + commitHash: null, + }); + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + setRepoSource("remote"); + setTabsResetKey((key) => key + 1); + }; return ( @@ -781,101 +842,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { />
-
-
- - {project.projectChannelId ? ( - - ) : null} -
-
+ { + void goChannel(channelId); + }} + onGoProjectHome={handleGoToProjectHome} + onGoProjects={() => { + void goProjects(); + }} + project={project} + />
@@ -886,7 +865,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {

{project.name}

- {safeWebUrl ? ( + {repoRemote.webUrl && + (repoRemote.host.kind !== "external" || + repoSource === "local") ? (
+
+ + Repository + + +
+ [...comments].sort( + (left, right) => + left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ), + [comments], + ); + const earlierCommentCount = Math.max( + 0, + orderedComments.length - COLLAPSED_COMMENT_COUNT, + ); + const visibleComments = + isExpanded || earlierCommentCount === 0 + ? orderedComments + : orderedComments.slice(-COLLAPSED_COMMENT_COUNT); + const displayedComments = isCollapsed ? [] : visibleComments; + + if (orderedComments.length === 0) { + return

No comments yet.

; + } + + return ( +
+ + + {!isCollapsed && earlierCommentCount > 0 && !isExpanded ? ( + + ) : null} + + {displayedComments.map((comment, index) => ( +
+
+ {index < displayedComments.length - 1 ? ( + + ) : null} + + + +
+
+
+ + + {resolveUserLabel({ profiles, pubkey: comment.author })} + + + + {relativeTime(comment.createdAt)} + +
+ +
+
+ ))} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 104000e55..6f34248dd 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -4,8 +4,8 @@ import { toast } from "sonner"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { - type Project, type ProjectIssue, + type Repository as Project, useCreateProjectIssueCommentMutation, useProjectIssuesQuery, } from "@/features/projects/hooks"; @@ -22,6 +22,8 @@ import { ProjectFeedRowCluster, ProjectFeedRowMonoCell, } from "./ProjectFeedRow"; +import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline"; +import { ProjectOriginReference } from "./ProjectOriginReference"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; @@ -67,29 +69,6 @@ function issueMembers( }); } -function AuthorIdentity({ - profiles, - pubkey, - role, -}: { - profiles?: UserProfileLookup; - pubkey: string; - role?: React.ReactNode; -}) { - const profile = profiles?.[normalizePubkey(pubkey)]; - return ( - - ); -} - function IssueRow({ issue, onOpen, @@ -141,10 +120,15 @@ function IssueRow({ trailing={ <> {issue.comments.length > 0 ? ( - + ) : null}
-

+

Issue from {authorLabel} +

{issue.title}{" "} @@ -228,37 +216,26 @@ export function ProjectIssueDetail({

+

Add Your Comment

- {issue.comments.length > 0 ? ( -
- {issue.comments.map((item) => ( -
-
- -
- -
- ))} -
- ) : ( -

No comments yet.

- )} - +
+ +
diff --git a/desktop/src/features/projects/ui/ProjectOriginReference.tsx b/desktop/src/features/projects/ui/ProjectOriginReference.tsx new file mode 100644 index 000000000..e9037372c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectOriginReference.tsx @@ -0,0 +1,56 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; + +export function ProjectOriginReference({ + agentName, + channelId, +}: { + agentName?: string | null; + channelId?: string | null; +}) { + const { goChannel } = useAppNavigation(); + const channelsQuery = useChannelsQuery({ enabled: Boolean(channelId) }); + const channel = channelsQuery.data?.find( + (candidate) => candidate.id === channelId, + ); + + if (channelId) { + return ( + + started from + {channel ? ( + + ) : ( + a public channel + )} + (author-claimed) + + ); + } + + if (agentName) { + return ( + + started privately with + + {agentName} + + + ); + } + + return null; +} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index d0e5c76be..de9044e1a 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -9,11 +9,11 @@ import type * as React from "react"; import { cn } from "@/shared/lib/cn"; import type { - Project, ProjectPullRequest, ProjectRepoContributor, ProjectRepoFile, ProjectRepoSnapshot, + Repository as Project, } from "@/features/projects/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { @@ -21,6 +21,7 @@ import { languageForPath, topLanguagesFromCounts, } from "@/features/projects/lib/projectLanguages"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; @@ -29,7 +30,10 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; type ProjectOverviewPanelProps = { contributors: ProjectRepoContributor[]; + externalHost?: string; + externalUrl?: string | null; files: ProjectRepoFile[]; + gitDataState: GitDataState; project: Project; onViewContributors: () => void; profiles?: UserProfileLookup; @@ -38,11 +42,10 @@ type ProjectOverviewPanelProps = { snapshot: ProjectRepoSnapshot | null | undefined; /** Branch picker + remote/local toggle for the readme header. */ sourceControls?: RepoSourceHeaderControls; + unavailableReason?: ProjectRepoUnavailableReason; }; -function shortHash(hash: string | undefined) { - return hash ? hash.slice(0, 7) : "None"; -} +export type GitDataState = "checking" | "available" | "empty" | "unavailable"; function topLanguages(files: ProjectRepoFile[]) { const counts: Record = {}; @@ -139,7 +142,10 @@ export function OverviewRailSection({ export function ProjectOverviewPanel({ contributors, + externalHost, + externalUrl, files, + gitDataState, onViewContributors, project, profiles, @@ -147,91 +153,115 @@ export function ProjectOverviewPanel({ readmeFile, snapshot, sourceControls, + unavailableReason, }: ProjectOverviewPanelProps) { const languages = topLanguages(files); const people = projectPeople(project); const latestCommit = snapshot?.latestCommit ?? null; + const gitDataAvailable = gitDataState === "available"; + const unavailableSplash = gitDataState === "unavailable"; return (
{/* ReadmePanel renders its own "no README" fallback while keeping the branch + source controls reachable. */} - +
- + + + {languages.length > 0 ? ( + + ) : ( +

+ No language data is available yet. +

+ )} +
+ +
+
+
+ + Pull Requests +
+
+ {pullRequests.length} +
+
+
+
+ +
+
+
+ + Branch +
+
+ {project.defaultBranch} +
+
+
+
+ + Latest +
+
+ {gitDataAvailable && latestCommit + ? latestCommit.hash.slice(0, 7) + : "—"} +
+
+
+
+ + Files +
+
+ {gitDataAvailable ? files.length : "—"} +
+
+
+
+ + Contributors +
+
+ {gitDataAvailable ? contributors.length : "—"} +
+
+
+
+ + ) : null}
); } diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index ae4172578..7e67ea3ae 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -28,9 +28,9 @@ import * as React from "react"; import { toast } from "sonner"; import { - type Project, type ProjectPullRequest, type ProjectPullRequestCommentAnchor, + type Repository as Project, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews"; diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index c93691a92..54e3689e4 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -16,13 +16,12 @@ import { import * as React from "react"; import { toast } from "sonner"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { ProjectOriginReference } from "./ProjectOriginReference"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { - type Project, type ProjectPullRequest, + type Repository as Project, type ProjectPullRequestCommentAnchor, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; @@ -331,14 +330,6 @@ export function PullRequestDetailHeader({ pullRequest: ProjectPullRequest; }) { const authorLabel = labelForPubkey(pullRequest.author, profiles); - const sourceChannelId = pullRequest.channelId; - const { goChannel } = useAppNavigation(); - const channelsQuery = useChannelsQuery({ - enabled: Boolean(sourceChannelId), - }); - const sourceChannel = channelsQuery.data?.find( - (channel) => channel.id === sourceChannelId, - ); return (
@@ -364,27 +355,10 @@ export function PullRequestDetailHeader({ created {relativeTime(pullRequest.createdAt)} - {sourceChannelId ? ( - - linked from - {sourceChannel ? ( - - ) : ( - an unavailable channel - )} - (author-claimed) - - ) : null} +

); diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx index 9150f5a0c..290733aec 100644 --- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx +++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx @@ -1,6 +1,19 @@ -import { BookOpen } from "lucide-react"; +import { + BookOpen, + CircleAlert, + CloudOff, + DownloadCloud, + ExternalLink, + GitBranch, + Globe, + Loader2, + LockKeyhole, + RefreshCw, +} from "lucide-react"; import type { ProjectRepoFile } from "@/features/projects/hooks"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; +import { Button } from "@/shared/ui/button"; import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown"; import { baseName, @@ -13,6 +26,7 @@ import { RepoSyncActionButton, RepositoryBranchDropdown, } from "./ProjectRepositorySource"; +import { GitHubMark } from "./GitHubMark"; export function findReadmeFile(files: ProjectRepoFile[]) { const readmes = files.filter((file) => @@ -78,9 +92,17 @@ function normalizeReadmeMarkdown(content: string) { export function ReadmePanel({ file, + gitDataState, + externalHost, + externalUrl, sourceControls, + unavailableReason, }: { file: ProjectRepoFile | null; + gitDataState: "checking" | "available" | "empty" | "unavailable"; + externalHost?: string; + externalUrl?: string | null; + unavailableReason?: ProjectRepoUnavailableReason; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; }) { @@ -125,13 +147,146 @@ export function ReadmePanel({ ); + if (gitDataState === "checking") { + return ( +
+ {header} +
+ + Loading repository… +
+
+ ); + } + + if (gitDataState === "unavailable") { + const reason = unavailableReason ?? "unknown"; + const unavailableContent = { + authentication: { + description: + "Buzz could not authenticate with this repository. Check your access and try again.", + icon: LockKeyhole, + title: "Repository access failed", + }, + missing: { + description: + "The project announcement exists, but its git repository was not found on the Buzz relay.", + icon: CircleAlert, + title: "Repository not initialized", + }, + network: { + description: + "The Buzz git service could not be reached. Check your connection and try again.", + icon: CloudOff, + title: "Couldn’t reach repository", + }, + ref: { + description: + "The selected branch is advertised by the project but is missing from its git remote.", + icon: GitBranch, + title: "Branch unavailable", + }, + unknown: { + description: + "Buzz could not load this repository. Try again or contact the project owner.", + icon: CircleAlert, + title: "Repository unavailable", + }, + } satisfies Record< + ProjectRepoUnavailableReason, + { + description: string; + icon: typeof CircleAlert; + title: string; + } + >; + const unavailable = unavailableContent[reason]; + const UnavailableIcon = unavailable.icon; + + return ( +
+
+
+ {externalHost === "github.com" ? ( + + ) : externalHost ? ( + + ) : ( + + )} +
+

+ {externalHost + ? `Code hosted on ${externalHost}` + : unavailable.title} +

+

+ {externalHost + ? "Clone this repository locally to explore its files, commits, and contributors in Buzz." + : unavailable.description} +

+ {externalUrl ? ( +
+ {externalUrl} + + ) : null} +
+ {!externalHost && sourceControls?.onFetch ? ( + + ) : null} + {externalHost && sourceControls?.onCloneLocal ? ( + + ) : null} + {externalUrl ? ( + + ) : null} +
+
+
+ ); + } + if (!file?.previewContent) { return (
- {sourceControls ? header : null} + {header}
- Add a README to this repository to describe setup, usage, and project - context. + {gitDataState === "empty" + ? "No files have been pushed to this repository yet." + : "Add a README to this repository to describe setup, usage, and project context."}
); diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx new file mode 100644 index 000000000..f1a9c8a15 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; +import { Check, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; +import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository"; +import { useAttachProjectRepositoryMutation } from "@/features/projects/useAttachProjectRepository"; +import { useBindProjectRepositoryChannelMutation } from "@/features/projects/useBindProjectRepositoryChannel"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { AddProjectRepositoryDialog } from "./AddProjectRepositoryDialog"; +import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog"; +import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker"; + +export function ProjectRepositoryManagement({ + identityPubkey, + onChange, + project, + projects, + repository, +}: { + identityPubkey?: string; + onChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository; +}) { + const [createOpen, setCreateOpen] = React.useState(false); + const [attachOpen, setAttachOpen] = React.useState(false); + const channelsQuery = useChannelsQuery(); + const createMutation = useAddProjectRepositoryMutation(); + const attachMutation = useAttachProjectRepositoryMutation(); + const repairMutation = useBindProjectRepositoryChannelMutation(); + const canEdit = identityPubkey?.toLowerCase() === project.owner.toLowerCase(); + const accessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); + const inheritedChannelId = [ + repository.channelId, + project.projectChannelId, + project.repositories.find( + (candidate) => candidate.id !== repository.id && candidate.channelId, + )?.channelId, + ].find( + (candidate) => + candidate && accessChannels.some((channel) => channel.id === candidate), + ); + const canManageAccess = + accessChannels.length > 0 && + identityPubkey?.toLowerCase() === repository.owner.toLowerCase(); + const attachCandidates = React.useMemo(() => { + const currentAddresses = new Set(project.repositoryAddresses); + const candidates = new Map(); + for (const candidateProject of projects) { + for (const candidate of candidateProject.repositories) { + if (!currentAddresses.has(candidate.repoAddress)) { + candidates.set(candidate.repoAddress, candidate); + } + } + } + return [...candidates.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); + }, [project.repositoryAddresses, projects]); + + return ( + <> + { + const result = await createMutation.mutateAsync(input); + onChange(result.repository.id); + toast.success(`Repository "${result.repository.name}" created.`); + }} + onOpenChange={setCreateOpen} + open={createOpen} + project={project} + /> + { + const result = await attachMutation.mutateAsync({ + project, + repository: candidate, + }); + onChange(result.repository.id); + toast.success(`Repository "${result.repository.name}" added.`); + }} + onOpenChange={setAttachOpen} + open={attachOpen} + project={project} + repositories={attachCandidates} + /> + setAttachOpen(true) : undefined} + onChange={onChange} + onCreate={canEdit ? () => setCreateOpen(true) : undefined} + project={project} + repository={repository} + /> + {canManageAccess ? ( + + + + + + Repository access channel + {accessChannels.map((channel) => ( + { + if (channel.id === repository.channelId) return; + void repairMutation + .mutateAsync({ + channelId: channel.id, + repository, + }) + .then(() => { + toast.success( + `Repository access set to #${channel.name}.`, + ); + }) + .catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to update repository access.", + ); + }); + }} + > + #{channel.name} + {channel.id === repository.channelId ? ( + + ) : null} + + ))} + + + ) : null} + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 9aa67189f..c4becd905 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -618,6 +618,7 @@ export function RepositoryFilesPanel({ profiles, fallbackAuthorPubkey, sourceControls, + unavailableMessage, }: { files: ProjectRepoFile[]; snapshot: ProjectRepoSnapshot | null | undefined; @@ -627,6 +628,7 @@ export function RepositoryFilesPanel({ fallbackAuthorPubkey?: string; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; + unavailableMessage?: string; }) { const [currentPath, setCurrentPath] = React.useState(""); const [selectedFile, setSelectedFile] = @@ -683,11 +685,13 @@ export function RepositoryFilesPanel({ // remote/local toggle must stay reachable when one source fails to load. const stateMessage = isLoading ? "Loading repository files…" - : error - ? "Could not load the repository file tree." - : files.length === 0 - ? "No files have been pushed yet." - : null; + : unavailableMessage + ? unavailableMessage + : error + ? "Could not load the repository file tree." + : files.length === 0 + ? "No files have been pushed yet." + : null; if (stateMessage) { if (!sourceControls) { return ( diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx new file mode 100644 index 000000000..889ac0af3 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx @@ -0,0 +1,130 @@ +import { + Check, + ChevronDown, + FolderPlus, + GitBranch, + Link, + Plus, +} from "lucide-react"; + +import type { Project, Repository } from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function ProjectRepositoryPicker({ + onAttach, + onChange, + onCreate, + project, + repository, +}: { + onAttach?: () => void; + onChange: (repositoryId: string) => void; + onCreate?: () => void; + project: Project; + repository: Repository; +}) { + const repositoryLabel = ( + <> + + {repository.name} + + ); + const unavailableRepositories = project.unavailableRepositoryAddresses ?? []; + + return ( +
+ {project.repositoryAddresses.length === 1 ? ( +
+ {repositoryLabel} +
+ ) : ( + + + + + + Repositories + {project.repositories.map((candidate) => ( + onChange(candidate.id)} + > + {candidate.name} + {candidate.id === repository.id ? ( + + ) : null} + + ))} + {unavailableRepositories.map((address) => ( + + + {address.slice(address.indexOf(":", 6) + 1)} + + + Unavailable + + + ))} + + + )} + {onCreate && onAttach ? ( + + + + + + + + Create new repository + + + + Add existing repository + + + + ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx index 83943b283..f1c9ecd45 100644 --- a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx @@ -2,7 +2,9 @@ import { ChevronDown, Cloud, DownloadCloud, + ExternalLink, GitBranch, + Globe, HardDrive, Loader2, Plus, @@ -23,6 +25,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { GitHubMark } from "./GitHubMark"; import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; /** Branch picker shared by the readme and files panel headers. */ @@ -181,6 +184,8 @@ export type RepoSourceHeaderControls = { localDisabled: boolean; localLabel: string; remoteLabel: string; + remoteKind?: "buzz" | "external"; + externalUrl?: string | null; /** Clones the repository when no local checkout is available. */ onCloneLocal?: () => void; clonePending?: boolean; @@ -213,7 +218,13 @@ export function RepoSourceDropdown({ }) { const isLocal = controls.source === "local"; const cloneLocal = controls.localDisabled && controls.onCloneLocal; - const SourceIcon = isLocal ? HardDrive : Cloud; + const RemoteIcon = + controls.remoteKind === "external" + ? controls.remoteLabel === "github.com" + ? GitHubMark + : Globe + : Cloud; + const SourceIcon = isLocal ? HardDrive : RemoteIcon; return ( @@ -238,7 +249,7 @@ export function RepoSourceDropdown({ value={controls.source} > - + {controls.remoteLabel} {!cloneLocal ? ( @@ -280,6 +291,23 @@ export function RepoSyncActionButton({ }: { controls: RepoSourceHeaderControls; }) { + if (controls.remoteKind === "external") { + return controls.externalUrl ? ( + + ) : null; + } + const pull = controls.canPull && controls.onPull; const push = controls.canPush && controls.onPush; diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index a6fd32990..1548f3607 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -16,11 +16,14 @@ import type { ProjectRepoContributor, ProjectRepoDiff, ProjectRepoSnapshot, + Repository, } from "@/features/projects/hooks"; import { commitAuthorPubkeysFromPullRequests, type ViewerGitIdentity, } from "@/features/projects/lib/projectContributorMatching"; +import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost"; +import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Tabs, TabsContent } from "@/shared/ui/tabs"; @@ -31,7 +34,10 @@ import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton"; -import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; +import { + type GitDataState, + ProjectOverviewPanel, +} from "./ProjectOverviewPanel"; import { PullRequestDetailHeader, PullRequestMetaRail, @@ -56,7 +62,11 @@ import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; type CreatePullRequestAction = { projects: Project[]; reposDir?: string | null; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; }; type CreateIssueAction = { @@ -116,6 +126,7 @@ export function WorkspaceTabs({ localSnapshotError, localSnapshotLoading, project, + projectId, repoDiff, repoDiffError, repoDiffLoading, @@ -138,6 +149,7 @@ export function WorkspaceTabs({ profiles, repoContributors, repoSource, + repoHost, sourceControls, terminalTitle, viewerGitIdentity, @@ -151,7 +163,8 @@ export function WorkspaceTabs({ localSnapshot: ProjectLocalRepoSnapshot | null | undefined; localSnapshotError: unknown; localSnapshotLoading: boolean; - project: Project; + project: Repository; + projectId: string; repoDiff: ProjectRepoDiff | null | undefined; repoDiffError: unknown; repoDiffLoading: boolean; @@ -175,6 +188,7 @@ export function WorkspaceTabs({ profiles?: UserProfileLookup; repoContributors: ProjectRepoContributor[]; repoSource: "remote" | "local"; + repoHost: ProjectRepoHost; /** Branch picker + remote/local toggle for the Code tab header. */ sourceControls?: RepoSourceHeaderControls; terminalTitle?: string; @@ -191,6 +205,23 @@ export function WorkspaceTabs({ displayedSnapshot?.contributors ?? repoContributors; const files = displayedSnapshot?.files ?? []; const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); + const externalHost = + repoSource === "remote" && repoHost.kind === "external" + ? repoHost.host + : undefined; + const gitDataState: GitDataState = displayedSnapshotLoading + ? "checking" + : externalHost || displayedSnapshotError || !displayedSnapshot + ? "unavailable" + : files.length === 0 + ? "empty" + : "available"; + const unavailableReason = + gitDataState === "unavailable" && !externalHost + ? projectRepoUnavailableReason(displayedSnapshotError) + : undefined; + const repositoryLoaded = + gitDataState === "available" || gitDataState === "empty"; const commitAuthorPubkeys = React.useMemo( () => commitAuthorPubkeysFromPullRequests(pullRequests), [pullRequests], @@ -199,6 +230,15 @@ export function WorkspaceTabs({ pullRequests.find( (pullRequest) => pullRequest.id === selectedPullRequestId, ) ?? null; + const selectedCommitPullRequest = React.useMemo( + () => + pullRequests.find( + (pullRequest) => + pullRequest.commit === selectedCommitHash || + pullRequest.initialCommit === selectedCommitHash, + ), + [pullRequests, selectedCommitHash], + ); const isPullRequestSelected = Boolean(selectedPullRequest); const [selectedTab, setSelectedTab] = React.useState("overview"); const [pullRequestCommentTarget, setPullRequestCommentTarget] = @@ -278,34 +318,36 @@ export function WorkspaceTabs({ onValueChange={handleTabChange} value={selectedTab} > -
- - {onOpenTerminal ? ( - - ) : null} - {updatePullRequestAction ? ( - - ) : null} -
+ {repositoryLoaded ? ( +
+ + {onOpenTerminal ? ( + + ) : null} + {updatePullRequestAction ? ( + + ) : null} +
+ ) : null} {selectedPullRequest ? (
{/* Two full-height columns: the meta rail runs all the way to the @@ -370,7 +412,10 @@ export function WorkspaceTabs({ setSelectedTab("contributors")} profiles={profiles} project={project} @@ -378,6 +423,7 @@ export function WorkspaceTabs({ readmeFile={readmeFile} snapshot={displayedSnapshot} sourceControls={sourceControls} + unavailableReason={unavailableReason} /> @@ -395,6 +441,8 @@ export function WorkspaceTabs({ diff={commitDiff} diffError={commitDiffError} diffLoading={commitDiffLoading} + originAgentName={selectedCommitPullRequest?.originAgentName} + originChannelId={selectedCommitPullRequest?.channelId} profiles={profiles} /> ) : ( @@ -480,6 +528,11 @@ export function WorkspaceTabs({ profiles={profiles} snapshot={displayedSnapshot} sourceControls={sourceControls} + unavailableMessage={ + externalHost + ? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.` + : undefined + } /> @@ -491,7 +544,7 @@ export function WorkspaceTabs({ {createPullRequestAction && createPullRequestOpen ? ( void; - onOpenIssue: (project: Project, issue: ProjectIssue) => void; + onOpenIssue: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; onOpenProject: (project: Project) => void; onOpenPullRequest: ( project: Project, + repository: Repository, pullRequest: ProjectPullRequest, ) => void; profiles?: UserProfileLookup; @@ -129,10 +141,15 @@ function buildActivityItems({ }); } - for (const { project, pullRequest } of pullRequests) { - const target = { type: "pull-request", project, pullRequest } as const; + for (const { project, pullRequest, repository } of pullRequests) { + const target = { + type: "pull-request", + project, + pullRequest, + repository, + } as const; items.push({ - id: `pr:${pullRequest.id}`, + id: `pr:${repository.id}:${pullRequest.id}`, kind: "pull-request", createdAt: pullRequest.createdAt, actorPubkey: pullRequest.author, @@ -145,7 +162,7 @@ function buildActivityItems({ }); for (const update of pullRequest.updates) { items.push({ - id: `pr-update:${update.id}`, + id: `pr-update:${repository.id}:${update.id}`, kind: "commit", createdAt: update.createdAt, actorPubkey: update.author, @@ -172,7 +189,7 @@ function buildActivityItems({ ? "review-request" : "comment"; items.push({ - id: `pr-comment:${comment.id}`, + id: `pr-comment:${repository.id}:${comment.id}`, kind, createdAt: comment.createdAt, actorPubkey: comment.author, @@ -198,10 +215,10 @@ function buildActivityItems({ } } - for (const { project, issue } of issues) { - const target = { type: "issue", project, issue } as const; + for (const { project, issue, repository } of issues) { + const target = { type: "issue", project, issue, repository } as const; items.push({ - id: `issue:${issue.id}`, + id: `issue:${repository.id}:${issue.id}`, kind: "issue", createdAt: issue.createdAt, actorPubkey: issue.author, @@ -214,7 +231,7 @@ function buildActivityItems({ }); for (const comment of issue.comments) { items.push({ - id: `issue-comment:${comment.id}`, + id: `issue-comment:${repository.id}:${comment.id}`, kind: "comment", createdAt: comment.createdAt, actorPubkey: comment.author, @@ -422,10 +439,15 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) { } else if (item.target.type === "pull-request") { props.onOpenPullRequest( item.target.project, + item.target.repository, item.target.pullRequest, ); } else { - props.onOpenIssue(item.target.project, item.target.issue); + props.onOpenIssue( + item.target.project, + item.target.repository, + item.target.issue, + ); } }} onOpenProject={() => props.onOpenProject(item.target.project)} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 29705425f..52245da57 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -81,10 +81,19 @@ const REPO_CONTEXT_MARKER = "Workspace repositories:"; * with the first message of a conversation. */ function repoContextBlock(projects: readonly Project[]) { if (projects.length === 0) return ""; - const listed = projects + const repositories = projects.flatMap((project) => + project.repositories.map((repository) => ({ + label: + project.repositories.length > 1 + ? `${project.name} / ${repository.name}` + : project.name, + repoAddress: repository.repoAddress, + })), + ); + const listed = repositories .slice(0, MAX_CONTEXT_REPOS) - .map((project) => `- ${project.name} (${project.repoAddress})`); - const remaining = projects.length - listed.length; + .map((repository) => `- ${repository.label} (${repository.repoAddress})`); + const remaining = repositories.length - listed.length; return ["", "---", REPO_CONTEXT_MARKER, ...listed] .concat(remaining > 0 ? [`…and ${remaining} more`] : []) .join("\n"); diff --git a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx index 4550682c2..b1e64002a 100644 --- a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx @@ -12,12 +12,12 @@ const MENU_ITEM_CLASS = export function ProjectsCreateMenu({ onCreateIssue, + onCreateProject, onCreatePullRequest, - onCreateRepository, }: { onCreateIssue: () => void; + onCreateProject: () => void; onCreatePullRequest: () => void; - onCreateRepository: () => void; }) { const [open, setOpen] = React.useState(false); const containerRef = React.useRef(null); @@ -88,12 +88,12 @@ export function ProjectsCreateMenu({ > - + {includeDate ? ( ` · ${issue.status}` ) : ( @@ -182,6 +187,7 @@ function IssueListRow({
{loadNotice}
- {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> @@ -286,11 +294,13 @@ export function ProjectsIssuesList({ className={PROJECT_LIST_CONTAINER_CLASS} data-testid="projects-list-container" > - {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index b11e7d123..b31e120ab 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react"; +import { CircleDot, FolderGit2, Folders, GitPullRequest } from "lucide-react"; import type * as React from "react"; import type { @@ -7,14 +7,13 @@ import type { } from "@/features/projects/hooks"; export type ProjectsOverviewSection = + | "projects" | "repositories" | "prs" - | "local" | "issues"; type ProjectsOverviewPanelProps = { children: React.ReactNode; - localRepositoryCount: number; metadata: React.ReactNode; onSelectSection: (section: ProjectsOverviewSection) => void; projects: Project[]; @@ -27,7 +26,7 @@ function overviewStats( ) { return projects.reduce( (stats, project) => { - const summary = summaries?.[project.repoAddress]; + const summary = summaries?.[project.id]; return { issues: stats.issues + (summary?.issueCount ?? 0), prs: stats.prs + (summary?.prCount ?? 0), @@ -70,7 +69,6 @@ function StatPill({ export function ProjectsOverviewPanel({ children, - localRepositoryCount, metadata, onSelectSection, projects, @@ -84,6 +82,15 @@ export function ProjectsOverviewPanel({
onSelectSection("projects")} + /> + count + project.repositories.length, + 0, + )} icon={FolderGit2} label="Repositories" onClick={() => onSelectSection("repositories")} @@ -94,12 +101,6 @@ export function ProjectsOverviewPanel({ label="Pull requests" onClick={() => onSelectSection("prs")} /> - onSelectSection("local")} - /> [ project.owner, - ...project.contributors, - ...(summaries?.[project.repoAddress]?.participantPubkeys ?? []), + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), + ...(summaries?.[project.id]?.participantPubkeys ?? []), ].map(normalizePubkey), ), ), @@ -41,7 +44,7 @@ function overviewActivityByDay( ) { const merged: Record = {}; for (const project of projects) { - const byDay = summaries?.[project.repoAddress]?.activityByDay; + const byDay = summaries?.[project.id]?.activityByDay; if (!byDay) continue; for (const [day, count] of Object.entries(byDay)) { merged[day] = (merged[day] ?? 0) + count; diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index a1f28254e..f04acb5b8 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -4,6 +4,7 @@ import type { Project, ProjectPullRequest, ProjectPullRequestListItem, + Repository, } from "@/features/projects/hooks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; @@ -11,10 +12,10 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; +import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity"; import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; @@ -29,32 +30,16 @@ import { PROJECT_LIST_ROW_TRAILING_CLASS, } from "./projectListRowStyles"; -/** Author name that opens the user profile popover. */ -function AuthorNameButton({ - label, - pubkey, -}: { - label: string; - pubkey: string; -}) { - return ( - - - - ); -} - type ProjectsPullRequestsListProps = { error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; isRetrying: boolean; - onOpen: (project: Project, pullRequest: ProjectPullRequest) => void; + onOpen: ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => void; onRetry: () => void; profiles?: UserProfileLookup; pullRequests: ProjectPullRequestListItem[]; @@ -140,8 +125,9 @@ function PullRequestGridCard({ created {relativeTime(pullRequest.createdAt)} by{" "} - @@ -200,11 +186,13 @@ function PullRequestListRow({ #{pullRequest.id.slice(0, 8)} - - by{" "} - + by + · @@ -291,10 +279,12 @@ export function ProjectsPullRequestsList({
{loadNotice}
- {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} @@ -312,10 +302,12 @@ export function ProjectsPullRequestsList({ className={PROJECT_LIST_CONTAINER_CLASS} data-testid="projects-list-container" > - {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 5e4ee7320..925e708c3 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -59,7 +59,8 @@ export function ProjectsToolbar({ label: string; value: ProjectsFilter; }> = [ - { label: "Overview", value: "all" }, + { label: "Activity", value: "all" }, + { label: "Projects", value: "projects" }, { label: "Repositories", value: "repositories" }, { label: "Pull Requests", value: "prs" }, { label: "Issues", value: "issues" }, @@ -82,6 +83,7 @@ export function ProjectsToolbar({ option.value === "all" && "pl-0 after:left-0", filter === option.value && SELECTED_MENU_ITEM_CLASSES, )} + data-testid={`projects-section-${option.value}`} key={option.value} onClick={() => onFilterChange(option.value)} type="button" diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index d0a3d2eb9..72a514cea 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -7,14 +7,21 @@ import { type Project, type ProjectIssue, type ProjectPullRequest, + type Repository, useDeleteProjectMutation, useProjectActivitySummariesQuery, useProjectLocalRepositoriesQuery, useProjectsQuery, useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; +import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; +import { + projectRepoHostForProject, + projectRepoHostForRepository, +} from "@/features/projects/lib/projectRepoHost"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { EmptyFilteredState, @@ -37,7 +44,14 @@ import { ProjectsToolbar, ProjectsViewModeToggle, } from "@/features/projects/ui/ProjectsToolbar"; -import { hasLocalCheckout } from "@/features/projects/lib/projectLocalRepos"; +import { + hasLocalCheckout, + hasLocalRepositoryCheckout, +} from "@/features/projects/lib/projectLocalRepos"; +import { + RepositoryGridCard, + RepositoryListRow, +} from "@/features/projects/ui/RepositoryCards"; import { getProjectUpdatedAt, isProjectMine, @@ -56,7 +70,6 @@ import { readStoredRepositoryScope, readStoredSort, readStoredViewMode, - uniqueRepositories, writeStoredFilter, writeStoredIssueScope, writeStoredPullRequestScope, @@ -65,15 +78,25 @@ import { writeStoredViewMode, } from "@/features/projects/lib/projectsViewHelpers"; import { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; const MANY_PROJECTS_THRESHOLD = 12; +const PROJECT_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsRepositoryScope; +}> = [ + { label: "All", value: "all" }, + { label: "My Projects", value: "mine" }, + { label: "Local", value: "local" }, +]; const REPOSITORY_SCOPE_OPTIONS: Array<{ label: string; value: ProjectsRepositoryScope; @@ -81,6 +104,8 @@ const REPOSITORY_SCOPE_OPTIONS: Array<{ { label: "All", value: "all" }, { label: "My Repositories", value: "mine" }, { label: "Local", value: "local" }, + { label: "Buzz-hosted", value: "buzz" }, + { label: "Linked", value: "linked" }, ]; const PULL_REQUEST_SCOPE_OPTIONS: Array<{ label: string; @@ -100,6 +125,7 @@ const ISSUE_SCOPE_OPTIONS: Array<{ export function ProjectsView() { const { goProject } = useAppNavigation(); const { activeCommunity } = useCommunities(); + const relayOrigin = useRelayOrigin(); const scrollIdleTimerRef = React.useRef | null>( null, ); @@ -152,10 +178,21 @@ export function ProjectsView() { : storedFilter; }); const activitySummariesQuery = useProjectActivitySummariesQuery( - filter === "prs" || filter === "issues" ? [] : projects, + filter === "prs" || filter === "issues" || filter === "repositories" + ? [] + : projects, + ); + const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( + filter === "repositories" ? projects : [], ); const [repositoryScope, setRepositoryScope] = - React.useState(() => readStoredRepositoryScope()); + React.useState(() => { + const storedScope = readStoredRepositoryScope(); + return filter === "projects" && + (storedScope === "buzz" || storedScope === "linked") + ? "all" + : storedScope; + }); const [pullRequestScope, setPullRequestScope] = React.useState(() => readStoredPullRequestScope()); const [issueScope, setIssueScope] = React.useState( @@ -164,11 +201,17 @@ export function ProjectsView() { const projectsWorkItemsQuery = useProjectsWorkItemsQuery( filter === "all" || filter === "prs" || filter === "issues" ? projects : [], ); - // One blobless clone per unique repository — only scan while the overview - // header (filter === "all") is actually visible. + // One blobless clone per primary Buzz repository, only while the overview + // header is visible. const snapshotProjects = React.useMemo( - () => (filter === "all" ? uniqueRepositories(projects) : []), - [filter, projects], + () => + filter === "all" + ? projects.filter( + (project) => + projectRepoHostForProject(project, relayOrigin).kind === "buzz", + ) + : [], + [filter, projects, relayOrigin], ); const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( snapshotProjects, @@ -191,10 +234,7 @@ export function ProjectsView() { ...new Set( [ ...projects.flatMap((project) => - projectPeople( - project, - activitySummariesQuery.data?.[project.repoAddress], - ), + projectPeople(project, activitySummariesQuery.data?.[project.id]), ), ...(projectsWorkItemsQuery.data?.pullRequests.items.flatMap( ({ pullRequest }) => [ @@ -231,10 +271,20 @@ export function ProjectsView() { [], ); - const handleFilterChange = React.useCallback((nextFilter: ProjectsFilter) => { - setFilter(nextFilter); - writeStoredFilter(nextFilter); - }, []); + const handleFilterChange = React.useCallback( + (nextFilter: ProjectsFilter) => { + if ( + nextFilter === "projects" && + (repositoryScope === "buzz" || repositoryScope === "linked") + ) { + setRepositoryScope("all"); + writeStoredRepositoryScope("all"); + } + setFilter(nextFilter); + writeStoredFilter(nextFilter); + }, + [repositoryScope], + ); const handleRepositoryScopeChange = React.useCallback( (scope: ProjectsRepositoryScope) => { @@ -275,30 +325,27 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - // Count projects with a checkout on this machine — matches what the - // "Local" filter actually lists, not every directory in the repos folder. - const localProjectCount = React.useMemo( - () => - projects.filter((project) => hasLocalCheckout(project, localRepoNames)) - .length, - [localRepoNames, projects], - ); - const visibleProjects = React.useMemo(() => { - // The PRs and Issues filters render dedicated lists - // (visiblePullRequests / visibleIssues), not project cards. - if (filter === "prs" || filter === "issues") { + if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; } const sortedProjects = projects .filter((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); if (repositoryScope === "mine") return isProjectMine(project, currentPubkey); if (repositoryScope === "local") return hasLocalCheckout(project, localRepoNames); + if (repositoryScope === "buzz") + return ( + projectRepoHostForProject(project, relayOrigin).kind === "buzz" + ); + if (repositoryScope === "linked") + return ( + projectRepoHostForProject(project, relayOrigin).kind === "external" + ); if (filter === "agents") { return projectHasAgent(project, people, profiles); } @@ -306,8 +353,8 @@ export function ProjectsView() { return true; }) .sort((left, right) => { - const leftSummary = activitySummariesQuery.data?.[left.repoAddress]; - const rightSummary = activitySummariesQuery.data?.[right.repoAddress]; + const leftSummary = activitySummariesQuery.data?.[left.id]; + const rightSummary = activitySummariesQuery.data?.[right.id]; if (sort === "name") { return left.name.localeCompare(right.name); } @@ -320,9 +367,7 @@ export function ProjectsView() { ); }); - return filter === "repositories" - ? uniqueRepositories(sortedProjects) - : sortedProjects; + return sortedProjects; }, [ activitySummariesQuery.data, currentPubkey, @@ -330,6 +375,76 @@ export function ProjectsView() { localRepoNames, profiles, projects, + relayOrigin, + repositoryScope, + sort, + ]); + + const visibleRepositories = React.useMemo(() => { + if (filter !== "repositories") return []; + const repositories = [ + ...new Map( + projects + .flatMap((project) => + project.repositories.map((repository) => ({ + project, + repository, + })), + ) + .map((item) => [item.repository.repoAddress, item]), + ).values(), + ]; + return repositories + .filter(({ repository }) => { + if (repositoryScope === "mine") { + if (!currentPubkey) return false; + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ) + ); + } + if (repositoryScope === "local") { + return hasLocalRepositoryCheckout(repository, localRepoNames); + } + if (repositoryScope === "buzz") { + return ( + projectRepoHostForRepository(repository, relayOrigin).kind === + "buzz" + ); + } + if (repositoryScope === "linked") { + return ( + projectRepoHostForRepository(repository, relayOrigin).kind === + "external" + ); + } + return true; + }) + .sort((left, right) => { + if (sort === "name") { + return left.repository.name.localeCompare(right.repository.name); + } + if (sort === "created") { + return right.repository.createdAt - left.repository.createdAt; + } + const leftUpdatedAt = + repositoryActivitySummariesQuery.data?.[left.repository.repoAddress] + ?.updatedAt ?? left.repository.createdAt; + const rightUpdatedAt = + repositoryActivitySummariesQuery.data?.[right.repository.repoAddress] + ?.updatedAt ?? right.repository.createdAt; + return rightUpdatedAt - leftUpdatedAt; + }); + }, [ + currentPubkey, + filter, + localRepoNames, + projects, + relayOrigin, + repositoryActivitySummariesQuery.data, repositoryScope, sort, ]); @@ -384,6 +499,13 @@ export function ProjectsView() { [goProject], ); + const handleOpenRepository = React.useCallback( + (project: Project, repository: Repository) => { + void goProject(project.id, { repositoryId: repository.id }); + }, + [goProject], + ); + const handleOpenCommit = React.useCallback( (project: Project, commitHash: string) => { void goProject(project.id, { commitHash }); @@ -392,24 +514,52 @@ export function ProjectsView() { ); const handleOpenPullRequest = React.useCallback( - (project: Project, pullRequest: ProjectPullRequest) => { - void goProject(project.id, { pullRequestId: pullRequest.id }); + ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => { + void goProject(project.id, { + pullRequestId: pullRequest.id, + repositoryId: repository.id, + }); }, [goProject], ); const handleOpenIssue = React.useCallback( - (project: Project, issue: ProjectIssue) => { - void goProject(project.id, { issueId: issue.id }); + (project: Project, repository: Repository, issue: ProjectIssue) => { + void goProject(project.id, { + issueId: issue.id, + repositoryId: repository.id, + }); }, [goProject], ); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback( - (project: Project) => - openTerminal(project, { - hasLocalCheckout: hasLocalCheckout(project, localRepoNames), + (project: Project) => { + const repository = selectProjectRepository(project, null); + if (!repository) return Promise.resolve(); + return openTerminal(repository, { + // Check the selected repository only — not all members — so the + // terminal affordance reflects the repository the button will open. + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), + }); + }, + [localRepoNames, openTerminal], + ); + const handleOpenRepositoryTerminal = React.useCallback( + (repository: Repository) => + openTerminal(repository, { + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), }), [localRepoNames, openTerminal], ); @@ -429,7 +579,7 @@ export function ProjectsView() { ); if (projectsQuery.isLoading) { - return null; + return ; } if (projectsQuery.isError) { @@ -451,7 +601,7 @@ export function ProjectsView() { return ; } - const repositoryItems = + const projectItems = visibleProjects.length === 0 ? ( ) : viewMode === "grid" ? ( @@ -462,7 +612,7 @@ export function ProjectsView() { )} > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( ); @@ -486,7 +639,7 @@ export function ProjectsView() { data-testid="projects-list-container" > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( ); @@ -506,6 +662,45 @@ export function ProjectsView() {
); + const repositoryItems = + visibleRepositories.length === 0 ? ( + + ) : viewMode === "grid" ? ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ) : ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ); + const listControls = (