From 1ff98fa685fdb7133dbc18437d23dcdeeb42ce6e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 12 Aug 2026 09:35:48 -0400 Subject: [PATCH 01/19] fix(desktop): launch Databricks OAuth from passive model discovery (#5607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [#5545](https://github.com/block/buzz/pull/5545) ever merges. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../src/commands/agent_models_databricks.rs | 169 +++++++++++++++--- .../commands/agent_models_databricks_tests.rs | 109 +++++++++++ .../src/commands/agent_models_tests.rs | 19 +- .../ui/personaModelDiscoveryStatus.test.mjs | 47 +++++ .../agents/ui/personaModelDiscoveryStatus.ts | 42 +++++ 5 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models_databricks_tests.rs diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 63b4564e61..4b6e512c05 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -1,7 +1,8 @@ //! Databricks v1/v2 model discovery and interactive reauthentication. -use std::collections::BTreeMap; -use std::sync::LazyLock; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; use crate::commands::agent_models_env::{ env_or_process_value, redaction_env_with_value, DiscoveryProvider, @@ -13,6 +14,77 @@ use crate::managed_agents::AgentModelsResponse; // callback listener/browser flow for the process-wide OAuth cache. static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); +// Hard cap on the interactive browser flow launched from a discovery surface. +// An abandoned SSO tab must fail discovery cleanly rather than wedge the +// dropdown forever. (`authenticate_databricks` has its own 60s callback wait; +// this outer bound also covers endpoint discovery and token exchange.) +const AUTH_FLOW_TIMEOUT: Duration = Duration::from_secs(150); + +// How long a failed/cancelled interactive sign-in suppresses re-launching the +// browser from passive surfaces. +pub(super) const AUTH_COOLDOWN: Duration = Duration::from_secs(5 * 60); + +/// Per-host record of a recently failed, cancelled, or timed-out interactive +/// sign-in. +/// +/// Passive discovery surfaces fire on every form-state change, so without this +/// a cancelled SSO page would re-pop the browser on the very next keystroke. +/// Entries expire so a genuine later retry still launches; the saved-model +/// picker bypasses the cooldown and a success clears it. +#[derive(Default)] +pub(super) struct AuthCooldown { + until: Mutex>, +} + +impl AuthCooldown { + fn map(&self) -> MutexGuard<'_, HashMap> { + // The critical sections below are panic-free map ops, so recover from a + // poisoned lock rather than wedge every future sign-in on one panic. + self.until + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub(super) fn is_active(&self, host: &str, now: Instant) -> bool { + let mut map = self.map(); + match map.get(host) { + Some(&expiry) if now < expiry => true, + Some(_) => { + map.remove(host); + false + } + None => false, + } + } + + pub(super) fn record(&self, host: &str, now: Instant) { + self.map().insert(host.to_string(), now + AUTH_COOLDOWN); + } + + pub(super) fn clear(&self, host: &str) { + self.map().remove(host); + } + + /// Whether the interactive browser flow may launch now under `auth_intent`. + /// Passive surfaces are suppressed while a per-host cooldown is active; the + /// explicit picker path always launches and clears any stale suppression. + pub(super) fn permits_launch( + &self, + auth_intent: DatabricksAuthIntent, + host: &str, + now: Instant, + ) -> bool { + if auth_intent.respects_cooldown() { + !self.is_active(host, now) + } else { + self.clear(host); + true + } + } +} + +static AUTH_COOLDOWNS: LazyLock = LazyLock::new(AuthCooldown::default); + pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { matches!( provider @@ -50,8 +122,14 @@ pub(super) enum DatabricksAuthIntent { } impl DatabricksAuthIntent { - fn allows_interactive_auth(self) -> bool { - matches!(self, Self::InteractiveModelPicker) + /// Passive draft discovery honors (and, on failure, writes) the per-host + /// cooldown so a cancelled SSO page does not re-pop on the next form + /// keystroke. The saved-model picker is an explicit user action, so it + /// bypasses the cooldown and clears it before launching. Both surfaces + /// launch the browser flow (Phase 2 goose-parity); this predicate is the + /// only behavioral difference between them. + fn respects_cooldown(self) -> bool { + matches!(self, Self::PassiveDraftDiscovery) } } @@ -60,11 +138,16 @@ pub(super) fn databricks_sign_in_required_error() -> String { .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) fn databricks_sign_in_timed_out_error() -> String { + "Databricks sign-in timed out; open the model picker to retry, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth(api_key: &str) -> bool { + // Phase 2: both discovery surfaces launch the browser flow when no static + // token is configured. Which surface is allowed to actually pop the browser + // (vs. respect a cooldown) is decided via `AuthCooldown::permits_launch`. + api_key.is_empty() } pub(super) async fn discover_databricks_models( @@ -93,22 +176,26 @@ pub(super) async fn discover_databricks_models( 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) => - { + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; match buzz_agent_pkg::discover_databricks_models(&config).await { + // A peer sign-in under the gate already succeeded. 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, - ) - })?; + // Passive surfaces suppress the browser while a recent + // failure/cancel is cooling down; the explicit picker path + // always launches (and clears any stale cooldown). + if !AUTH_COOLDOWNS.permits_launch(auth_intent, &host, Instant::now()) { + return Err(databricks_sign_in_required_error()); + } + run_interactive_databricks_auth( + buzz_agent_pkg::authenticate_databricks(&host), + AUTH_FLOW_TIMEOUT, + &AUTH_COOLDOWNS, + &host, + &redaction_env, + ) + .await?; buzz_agent_pkg::discover_databricks_models(&config) .await .map_err(|error| { @@ -172,3 +259,43 @@ fn format_redacted_error( let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); format!("{context}: {message}") } + +/// Run the interactive browser OAuth flow under a hard timeout and maintain the +/// per-host cooldown. Success clears the cooldown; a failure, cancel, or +/// timeout records it so passive surfaces stop re-launching the browser on the +/// next form keystroke. `timeout` is injected (production passes +/// [`AUTH_FLOW_TIMEOUT`]) so the timeout/cooldown policy is unit-testable +/// without a live browser. +pub(super) async fn run_interactive_databricks_auth( + auth: Fut, + timeout: Duration, + cooldowns: &AuthCooldown, + host: &str, + redaction_env: &BTreeMap, +) -> Result<(), String> +where + Fut: std::future::Future>, +{ + match tokio::time::timeout(timeout, auth).await { + Ok(Ok(())) => { + cooldowns.clear(host); + Ok(()) + } + Ok(Err(error)) => { + cooldowns.record(host, Instant::now()); + Err(format_redacted_error( + "Databricks sign-in failed", + &error, + redaction_env, + )) + } + Err(_elapsed) => { + cooldowns.record(host, Instant::now()); + Err(databricks_sign_in_timed_out_error()) + } + } +} + +#[cfg(test)] +#[path = "agent_models_databricks_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs new file mode 100644 index 0000000000..cb530ec59b --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs @@ -0,0 +1,109 @@ +//! Cooldown and interactive-auth policy tests for Databricks discovery. +//! +//! Housed as a child of `agent_models_databricks` (not the shared +//! `agent_models_tests`) so the async timeout/cooldown cases sit next to the +//! code they exercise and reach its `pub(super)` items directly via +//! `use super::*` — and so the shared test file stays under its size ratchet. + +use super::*; + +#[test] +fn databricks_cooldown_suppresses_passive_relaunch_but_never_the_picker() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let now = Instant::now(); + + // A fresh host permits either surface to launch. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + + // After a failed/cancelled attempt, passive discovery must NOT re-pop the + // browser while the window is active... + cooldowns.record(host, now); + assert!(!cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + + // ...but an explicit picker click always launches, and clears the window so + // a later passive read is unblocked too. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); +} + +#[test] +fn databricks_cooldown_expires_after_its_window_and_is_host_scoped() { + let cooldowns = AuthCooldown::default(); + let host = "https://a.cloud.databricks.com"; + let other = "https://b.cloud.databricks.com"; + let now = Instant::now(); + + cooldowns.record(host, now); + // A cooldown on one host never suppresses another. + assert!(!cooldowns.is_active(other, now)); + assert!(cooldowns.is_active(host, now)); + + // The window is closed the instant it elapses, so a genuine later retry + // launches again. + let after = now + AUTH_COOLDOWN; + assert!(!cooldowns.is_active(host, after)); +} + +#[tokio::test] +async fn databricks_interactive_auth_success_clears_a_prior_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + cooldowns.record(host, Instant::now()); + + let result = run_interactive_databricks_auth( + async { Ok(()) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + assert!(result.is_ok()); + assert!(!cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test] +async fn databricks_interactive_auth_failure_records_a_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + let result = run_interactive_databricks_auth( + async { Err(buzz_agent_pkg::AgentError::LlmAuth("closed the tab".into())) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a failed sign-in must surface an error"); + assert!(error.contains("Databricks sign-in failed")); + assert!(cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test(start_paused = true)] +async fn databricks_interactive_auth_timeout_records_cooldown_and_returns_timeout_copy() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + // An abandoned SSO tab: the flow never resolves. Under the paused clock the + // injected timeout fires deterministically without real waiting. + let result = run_interactive_databricks_auth( + std::future::pending::>(), + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a timed-out sign-in must surface an error"); + assert_eq!(error, databricks_sign_in_timed_out_error()); + assert!(cooldowns.is_active(host, Instant::now())); +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index e7d0e70fd0..6226acfd96 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -577,19 +577,12 @@ fn is_databricks_provider_matches_both_variants() { } #[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 - )); +fn databricks_interactive_auth_launches_only_without_a_static_token() { + // Phase 2: both surfaces launch the browser flow when the token is empty; + // the surface distinction is now cooldown-only (asserted separately). A + // configured static token still short-circuits interactive auth entirely. + assert!(should_start_interactive_auth("")); + assert!(!should_start_interactive_auth("static-token")); } #[test] diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index 548c9ccc0a..dfa086738f 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -69,6 +69,53 @@ test("model discovery status stays quiet for missing Databricks defaults", () => assert.equal(status, null); }); +test("Databricks sign-in-required is a muted note pointing at the picker and CLI", () => { + const status = formatModelDiscoveryErrorStatus( + new Error( + "Databricks sign-in is required; save this agent, then open its model picker to sign in, or run `buzz-agent auth databricks`", + ), + "databricks_v2", + ); + + assert.equal(status?.tone, "muted"); + assert.match(status?.message ?? "", /model picker/); + assert.match(status?.message ?? "", /buzz-agent auth databricks/); +}); + +test("Databricks sign-in failure warns and points at the explicit retry", () => { + const status = formatModelDiscoveryErrorStatus( + new Error("Databricks sign-in failed: oauth callback: access_denied"), + "databricks_v2", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /didn't complete/); + assert.match(status?.message ?? "", /model picker/); +}); + +test("Databricks sign-in timeout warns and points at the explicit retry", () => { + const status = formatModelDiscoveryErrorStatus( + new Error( + "Databricks sign-in timed out; open the model picker to retry, or run `buzz-agent auth databricks`", + ), + "databricks_v2", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /didn't complete/); + assert.match(status?.message ?? "", /buzz-agent auth databricks/); +}); + +test("other Databricks discovery failures fall through to the generic notice", () => { + const status = formatModelDiscoveryErrorStatus( + new Error("Databricks model discovery failed: relay offline"), + "databricks_v2", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /Using built-in model options/); +}); + test("auth-required errors name the agent and ask for sign-in", () => { // Real shape from run_agent_models_command wrapping buzz-acp stderr when // cursor-agent is signed out (spec ErrorCode::AuthRequired text). diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index b943f6455c..0d89526425 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -124,6 +124,16 @@ export function formatModelDiscoveryErrorStatus( return null; } + // Databricks transparent auth (agent_models_databricks.rs). The backend + // launches the browser OAuth flow itself from every discovery surface, so + // these are terminal outcomes the user should see, not raw error text. + // Matched on the stable error strings the backend emits (string matching is + // this file's convention until typed error codes arrive). + const databricksStatus = formatDatabricksAuthStatus(message); + if (databricksStatus !== null) { + return databricksStatus; + } + return { message: `Using built-in model options. Could not load live models for ${providerObjectLabel( provider, @@ -131,3 +141,35 @@ export function formatModelDiscoveryErrorStatus( tone: "warning", }; } + +/** + * Maps the terminal Databricks sign-in states to user-facing guidance, or null + * when the error is not a Databricks sign-in outcome. "Sign-in required" is a + * quiet muted note (a passive surface hit its cooldown, or an unsaved draft + * can't launch the browser); a failed, cancelled, or timed-out sign-in is a + * warning that points the user at the explicit retry path. + */ +function formatDatabricksAuthStatus( + message: string, +): PersonaModelDiscoveryStatus | null { + if (message.includes("Databricks sign-in is required")) { + return { + message: + "Databricks sign-in is required. Open the model picker to sign in, or run `buzz-agent auth databricks` in a terminal.", + tone: "muted", + }; + } + + if ( + message.includes("Databricks sign-in failed") || + message.includes("Databricks sign-in timed out") + ) { + return { + message: + "Databricks sign-in didn't complete. Open the model picker to retry, or run `buzz-agent auth databricks` in a terminal.", + tone: "warning", + }; + } + + return null; +} From 6e0631f6b5d2139e4e080bf94e27ecee8a3d4d74 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 12 Aug 2026 09:42:59 -0400 Subject: [PATCH 02/19] feat(acp): deliver channel description in prompt [Context] (#4552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels carry a kind-39000 `about` description that the harness never surfaced to agents. This delivers it in the per-turn `[Context]` block so an agent knows what a channel is for without having to ask. ## What changes - `relay::ChannelInfo` and `queue::PromptChannelInfo` gain a `description: Option` field. - The `about` tag is parsed in both metadata paths: the startup discovery map (`merge_discovered_channels`) and the lazy `fetch_channel_info` lookup. Blank or whitespace-only values become `None`. - `format_context_hints` renders a `Description:` line under `Channel:` for channel- and thread-scope turns. DM turns never render it. ## Safety - The description is newline-collapsed to a single line before rendering, so a multi-line `about` value can never spoof another `[Context]` field. - It is capped at 500 characters on a UTF-8 char boundary, with a `…` truncation marker. - Unresolved channel metadata renders no `Description:` line. Session creation is untouched — the description rides the existing per-turn `[Context]` block that already carries `Channel:`. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-acp/src/lib.rs | 3 + crates/buzz-acp/src/pool.rs | 47 +++++- crates/buzz-acp/src/queue.rs | 314 ++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 65 +++++++- 4 files changed, 418 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index fa348eeb3c..3563eec8e9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5031,6 +5031,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + description: None, }, ), ( @@ -5038,6 +5039,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + description: None, }, ), ]); @@ -5054,6 +5056,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "unknown".into(), channel_type: "unknown".into(), + description: None, }, )]); assert!( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 33bd5507fb..c1960b7bdb 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -524,6 +524,7 @@ impl ChannelInfoResolver { PromptChannelInfo { name: info.name, channel_type: info.channel_type, + description: info.description, }, )) }) @@ -2577,17 +2578,25 @@ pub(crate) async fn fetch_channel_info( let ev = events.first()?; let tags = ev.get("tags")?.as_array()?; let mut name = None; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { - if arr.first().and_then(|v| v.as_str()) == Some("name") { - name = arr.get(1).and_then(|v| v.as_str()); + match arr.first().and_then(|v| v.as_str()) { + Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), + _ => {} } } } let channel_type = crate::relay::channel_type_from_tags(tags); + let description = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); Some(PromptChannelInfo { name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, + description, }) } Ok(Err(e)) => { @@ -5803,6 +5812,7 @@ done"# crate::relay::ChannelInfo { name: "test-dm".into(), channel_type: "dm".into(), + description: None, }, )]), RestClient { @@ -5964,6 +5974,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" crate::relay::ChannelInfo { name: "test-dm".into(), channel_type: "dm".into(), + description: None, }, )]), RestClient { @@ -7855,6 +7866,38 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" server.abort(); } + /// A channel's `about` tag is parsed through the lazy-fetch path and + /// delivered as the resolved description. + #[tokio::test] + async fn test_channel_resolver_delivers_description() { + let id = Uuid::new_v4(); + let response = channel_metadata_response( + id, + &[ + ["name", "team-chat"], + ["t", "stream"], + ["about", "Engineering discussions"], + ], + ); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description.as_deref(), Some("Engineering discussions")); + server.abort(); + } + + /// A metadata event with no `about` tag yields no description. + #[tokio::test] + async fn test_channel_resolver_absent_description_when_no_about_tag() { + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description, None); + server.abort(); + } + /// A DM carries no useful name, so it gets the bare agent title (and no /// canvas section). #[tokio::test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 3bf1962242..8bcdcf2250 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1003,6 +1003,8 @@ pub struct ContextMessage { pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -1231,6 +1233,48 @@ fn resolve_reply_anchor( ) } +/// Maximum length (in characters) of a channel description rendered into `[Context]`. +/// +/// Limits prompt bloat from unusually long descriptions; a raw embedded newline +/// in a description must not be able to spoof another `[Context]` field, so +/// multiline text is collapsed to single-space-joined lines before truncation. +const MAX_DESCRIPTION_LEN: usize = 500; + +/// Append a `Description: …` line to a `[Context]` block when non-empty. +/// +/// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space +/// so a multi-line description cannot inject a fake `[Context]` field line. +/// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. +fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { + let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { + Some(d) if !d.is_empty() => d, + _ => return, + }; + // Collapse newlines to spaces so the description can never spoof another field. + let collapsed: String = desc + .split(['\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if collapsed.is_empty() { + return; + } + // Truncate at a character boundary (not byte boundary) to avoid splitting + // multi-byte sequences. + let truncated = if collapsed.chars().count() > MAX_DESCRIPTION_LEN { + let end = collapsed + .char_indices() + .nth(MAX_DESCRIPTION_LEN) + .map(|(i, _)| i) + .unwrap_or(collapsed.len()); + format!("{}…", &collapsed[..end]) + } else { + collapsed + }; + s.push_str(&format!("\nDescription: {truncated}")); +} + /// Format a `[Context]` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see @@ -1301,9 +1345,10 @@ fn format_context_hints( let mut s = format!( "[Context]\n\ Scope: thread\n\ - Channel: {channel_display}\n\ - Thread root: {root}" + Channel: {channel_display}" ); + append_channel_description(&mut s, channel_info); + s.push_str(&format!("\nThread root: {root}")); if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { s.push_str(&format!("\nParent: {parent}")); @@ -1318,8 +1363,11 @@ fn format_context_hints( let mut s = format!( "[Context]\n\ Scope: channel\n\ - Channel: {channel_display}\n\ - Hint: Use `buzz messages get --channel ` for recent messages if needed." + Channel: {channel_display}" + ); + append_channel_description(&mut s, channel_info); + s.push_str( + "\nHint: Use `buzz messages get --channel ` for recent messages if needed.", ); if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); @@ -3087,6 +3135,7 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + description: None, }; let prompt = format_prompt( @@ -3118,6 +3167,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -3230,6 +3280,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -3488,6 +3539,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -3587,6 +3639,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let trigger_only_prompt = format_prompt( @@ -3635,6 +3688,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // No context fetched — hints only. @@ -4130,6 +4184,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -4193,6 +4248,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -4961,4 +5017,254 @@ mod tests { "second extend must not move deadline backward (monotonic)" ); } + + // ── channel description delivery ───────────────────────────────────────── + + #[test] + fn test_append_channel_description_adds_description_line() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions".into()), + }; + let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + s.contains("\nDescription: Engineering discussions"), + "description must be appended; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_none() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: None, + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "no description must be appended when None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_channel_info_none() { + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, None); + assert!( + !s.contains("Description:"), + "no description must be appended when channel_info is None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_collapses_newlines_spoof_prevention() { + // A multiline description must not be able to inject a fake [Context] field. + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("Line one\nScope: injected\nLine two".into()), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + // The whole description is on a single Description line — no injected field. + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + assert_eq!( + desc_line, "Description: Line one Scope: injected Line two", + "multiline description must collapse to one line, never a fake field" + ); + assert_eq!( + s.lines().filter(|l| l.starts_with("Description:")).count(), + 1, + "exactly one Description line is rendered" + ); + } + + #[test] + fn test_append_channel_description_truncates_at_cap() { + let long_desc = "x".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + assert!( + desc_line.ends_with('…'), + "truncated description must end with '…'; got: {desc_line}" + ); + // Value = first MAX_DESCRIPTION_LEN chars + the "…" marker. + let value = desc_line.strip_prefix("Description: ").unwrap(); + assert_eq!( + value.chars().count(), + MAX_DESCRIPTION_LEN + 1, + "truncated value is exactly the cap plus the ellipsis marker" + ); + } + + #[test] + fn test_append_channel_description_multibyte_truncation_is_char_safe() { + // Truncation must land on a char boundary, never split a multi-byte code point. + let long_desc = "é".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + let value = desc_line.strip_prefix("Description: ").unwrap(); + assert_eq!(value.chars().count(), MAX_DESCRIPTION_LEN + 1); + } + + #[test] + fn test_append_channel_description_whitespace_only_is_absent() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("\n \r\n \n".into()), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "a whitespace-only description collapses to empty and is not rendered; got: {s}" + ); + } + + fn description_batch(ch: Uuid, event: Event) -> FlushBatch { + FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_channel_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for channel turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_thread_turn() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "reply in thread", + vec![vec![ + "e".into(), + "root123".into(), + "".into(), + "reply".into(), + ]], + ); + let batch = description_batch(ch, event); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: thread"), + "thread-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for thread turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_excludes_description_for_dm_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("hey")); + let ci = PromptChannelInfo { + name: "DM".into(), + channel_type: "dm".into(), + description: Some("This should not appear.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: dm"), + "dm-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "DM turn must not include a Description field; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_no_description_when_channel_metadata_unresolved() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + // channel_info None models unresolved metadata: no name, no description. + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: None, + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "unresolved metadata must not render a Description field; got: {prompt}" + ); + } } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 2cbb82411f..17a818867d 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -136,6 +136,8 @@ use crate::config::ChannelFilter; pub struct ChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { @@ -175,7 +177,7 @@ pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); + let mut meta_map: HashMap)> = HashMap::new(); let mut archived: std::collections::HashSet = std::collections::HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { @@ -186,11 +188,13 @@ pub(crate) fn merge_discovered_channels( let mut d_val = None; let mut name = None; let mut is_archived = false; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } @@ -206,7 +210,11 @@ pub(crate) fn merge_discovered_channels( } let ch_name = name.unwrap_or("unknown").to_string(); let ch_type = channel_type_from_tags(tags); - meta_map.insert(uuid, (ch_name, ch_type)); + let ch_desc = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + meta_map.insert(uuid, (ch_name, ch_type, ch_desc)); } } } @@ -217,10 +225,17 @@ pub(crate) fn merge_discovered_channels( if archived.contains(&uuid) { continue; } - let (name, channel_type) = meta_map + let (name, channel_type, description) = meta_map .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string(), None)); + map.insert( + uuid, + ChannelInfo { + name, + channel_type, + description, + }, + ); } map } @@ -4163,6 +4178,46 @@ mod tests { assert!(map.contains_key(&ch), "archived=false is treated as live"); } + #[test] + fn merge_discovered_channels_parses_about_as_description() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event( + ch, + "team", + &["t", "stream", "about", "Engineering discussions"] + )]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description.as_deref(), + Some("Engineering discussions") + ); + } + + #[test] + fn merge_discovered_channels_blank_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["about", " "])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description, None, + "a whitespace-only about tag is trimmed away to None" + ); + } + + #[test] + fn merge_discovered_channels_missing_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["t", "stream"])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!(map[&ch].description, None); + } + #[test] fn parse_ok_accepted() { let text = r#"["OK","abc123",true,""]"#; From c966b862fe8b9018c68c384b1680ca0173d0128c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 12 Aug 2026 11:07:16 -0400 Subject: [PATCH 03/19] fix(deps): bump webbrowser to 1.2.4 for RUSTSEC-2026-0257 (#5659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Bumps `webbrowser` from `1.2.1` to `1.2.4` in both lockfiles (`Cargo.lock` and `desktop/src-tauri/Cargo.lock`) to clear [RUSTSEC-2026-0257](https://rustsec.org/advisories/RUSTSEC-2026-0257). ## Why The advisory landed in the RustSec DB and flipped the `Security` job (`cargo-deny check`) red on `main` — the same job passed on identical lockfile state before the advisory was published. `webbrowser` 1.2.1 substitutes the URL into the Unix `BROWSER` env template *before* tokenizing, allowing browser argument injection (e.g. `--remote-debugging-port`). `crates/buzz-agent` calls `webbrowser::open()` for the OAuth flow (`crates/buzz-agent/src/auth.rs`) with an internally-constructed HTTPS URL, so practical exploitability is low, but the gate is correctly blocking. Fixed in `1.2.2`+. ## Scope Lockfile-only. The `crates/buzz-agent/Cargo.toml` constraint is already `webbrowser = "1"`, so no manifest change is needed. `webbrowser` 1.2.4 pulls in `objc2-app-kit` as a new transitive dependency; the `windows-sys` edge churn re-unifies to versions already present in the lockfile (no new `windows-sys` version is introduced). ## Verification - `cargo-deny check` passes locally on the pinned toolchain (`advisories ok, bans ok, licenses ok, sources ok`); RUSTSEC-2026-0257 no longer reported in either lockfile. - `cargo check -p buzz-agent` compiles clean against `webbrowser 1.2.4`. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- Cargo.lock | 43 +++++++++++++++++++------------ desktop/src-tauri/Cargo.lock | 50 ++++++++++++++++++------------------ 2 files changed, 52 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14af380d26..9ca778958a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1686,7 +1686,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2741,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6146,6 +6146,17 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -7464,7 +7475,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -8148,7 +8159,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8207,7 +8218,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8490,7 +8501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8987,7 +8998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9615,7 +9626,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9628,7 +9639,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10398,7 +10409,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10822,15 +10833,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -10982,7 +10993,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5449e4db96..2ec773356e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1624,7 +1624,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2291,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -2469,7 +2469,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2750,7 +2750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3268,7 +3268,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -5726,7 +5726,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5885,7 +5885,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6398,7 +6398,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7102,7 +7102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8128,7 +8128,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8859,7 +8859,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8929,7 +8929,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9196,7 +9196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9815,7 +9815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10829,10 +10829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10854,7 +10854,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11596,7 +11596,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11697,7 +11697,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12308,15 +12308,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -12548,7 +12548,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 63f961c7e4818a1d29f1185002c123e486bd4a19 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 12 Aug 2026 16:25:04 +0100 Subject: [PATCH 04/19] Refine channel settings and profile panels (#5574) ## Summary - simplify channel settings into concise detail, member, canvas, and action sections - align human and agent profiles around shared rows, segmented tabs, and top-level actions - add agent runtime presentation, sticky glass behavior, and scroll-linked action transitions ## Snapshots ### Channel settings ![Channel settings](https://raw.githubusercontent.com/block/buzz/72d3958374a6f31c0f15912a8307101415eed084/pr-5574--01-channel-settings.png) ### Agent info ![Agent info](https://raw.githubusercontent.com/block/buzz/72d3958374a6f31c0f15912a8307101415eed084/pr-5574--02-agent-info.png) ### Agent runtime ![Agent runtime](https://raw.githubusercontent.com/block/buzz/72d3958374a6f31c0f15912a8307101415eed084/pr-5574--03-agent-runtime.png) ## Validation - `pnpm -C desktop check` - `pnpm -C desktop test` (4,604 passed) - `pnpm -C desktop build:e2e` - focused channel settings and agent profile Playwright tests --------- Signed-off-by: kenny lopez Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> --- desktop/src/app/AppShell.tsx | 1 + desktop/src/app/AppShellOverlays.tsx | 27 + .../agent-memory/ui/MemorySection.tsx | 103 ++- .../agents/lib/managedAgentControlActions.ts | 2 +- .../features/agents/ui/AgentConfigPanel.tsx | 284 +++++-- .../features/agents/ui/AgentStatusBadge.tsx | 12 +- .../features/agents/ui/McpServersSection.tsx | 55 +- .../ui/ChannelManagementAuxiliaryPanel.tsx | 3 + .../channels/ui/ChannelManagementSheet.tsx | 407 ++++----- .../ui/ChannelManagementSheetRows.tsx | 460 ++++++++--- .../channels/ui/ChannelMemberAvatarStack.tsx | 75 ++ .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/channels/ui/channelFormStyles.ts | 2 +- .../home/ui/HomeMembersSidebarOverlay.tsx | 37 + desktop/src/features/home/ui/HomeView.tsx | 12 +- .../ui/ProfileTabContentTransition.tsx | 116 +++ .../profile/ui/UserProfileAgentActions.tsx | 137 +--- .../ui/UserProfileAgentManagementRows.tsx | 283 +++++++ .../profile/ui/UserProfileEditAgentDialog.tsx | 34 + .../features/profile/ui/UserProfilePanel.tsx | 200 +++-- .../ui/UserProfilePanelAgentDetails.tsx | 8 +- .../profile/ui/UserProfilePanelFields.tsx | 163 ++-- .../ui/UserProfilePanelFocusedViews.tsx | 203 +++++ .../profile/ui/UserProfilePanelFrame.tsx | 48 +- .../ui/UserProfilePanelHeaderContent.tsx | 16 + .../profile/ui/UserProfilePanelSections.tsx | 773 +++++++++--------- .../profile/ui/UserProfilePanelTabs.tsx | 556 +++++++------ .../profile/ui/UserProfilePanelUtils.ts | 6 +- .../profile/ui/UserProfilePopover.tsx | 275 +------ .../profile/ui/UserProfilePrimaryActions.tsx | 238 +++--- .../profile/ui/UserProfileRuntimePreview.tsx | 154 ++++ .../features/profile/ui/useProfileDmAction.ts | 49 -- .../profile/ui/useProfileEditAgentRequest.ts | 33 + .../ui/useProfileInteractionActions.ts | 285 +++++++ .../src/shared/layout/AuxiliaryPanel/index.ts | 1 + .../shared/layout/AuxiliaryPanelHeader.tsx | 4 +- desktop/src/shared/ui/HoverCopyIndicator.tsx | 78 ++ desktop/src/shared/ui/PanelSectionGroup.tsx | 52 ++ desktop/src/shared/ui/PubKey.tsx | 11 + desktop/src/testing/e2eBridge.ts | 5 + desktop/tests/e2e/agents.spec.ts | 12 +- desktop/tests/e2e/channel-controls.spec.ts | 6 +- desktop/tests/e2e/channels.spec.ts | 358 +++++++- .../e2e/config-bridge-screenshots.spec.ts | 50 +- desktop/tests/e2e/identity-archive.spec.ts | 18 +- desktop/tests/e2e/profile.spec.ts | 657 ++++++++++++++- .../e2e/pubkey-display-screenshots.spec.ts | 19 +- 47 files changed, 4455 insertions(+), 1874 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx create mode 100644 desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx create mode 100644 desktop/src/features/profile/ui/ProfileTabContentTransition.tsx create mode 100644 desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx create mode 100644 desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx create mode 100644 desktop/src/features/profile/ui/UserProfilePanelFocusedViews.tsx create mode 100644 desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx delete mode 100644 desktop/src/features/profile/ui/useProfileDmAction.ts create mode 100644 desktop/src/features/profile/ui/useProfileEditAgentRequest.ts create mode 100644 desktop/src/features/profile/ui/useProfileInteractionActions.ts create mode 100644 desktop/src/shared/ui/HoverCopyIndicator.tsx create mode 100644 desktop/src/shared/ui/PanelSectionGroup.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 73e40e8cd8..db10c12dc9 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -939,6 +939,7 @@ export function AppShell() { onSelectChannel={(channelId) => { void goChannel(channelId); }} + relayUrl={communitiesHook.activeCommunity?.relayUrl} /> { return { default: module.ChannelManagementSheet }; }); +const MembersSidebar = React.lazy(async () => { + const module = await import("@/features/channels/ui/MembersSidebar"); + return { default: module.MembersSidebar }; +}); + export type BrowseDialogType = "stream" | "forum" | null; type AppShellOverlaysProps = { @@ -30,6 +35,7 @@ type AppShellOverlaysProps = { onChannelManagementOpenChange: (open: boolean) => void; onDeleteActiveChannel: () => void; onSelectChannel: (channelId: string) => void; + relayUrl?: string; }; export function AppShellOverlays({ @@ -45,7 +51,11 @@ export function AppShellOverlays({ onChannelManagementOpenChange, onDeleteActiveChannel, onSelectChannel, + relayUrl, }: AppShellOverlaysProps) { + const [membersChannel, setMembersChannel] = React.useState( + null, + ); const [visibleBrowseDialogType, setVisibleBrowseDialogType] = React.useState(null); const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } = @@ -89,11 +99,28 @@ export function AppShellOverlays({ channel={activeChannel} currentPubkey={currentPubkey} onDeleted={onDeleteActiveChannel} + onOpenMembers={() => setMembersChannel(activeChannel)} onOpenChange={onChannelManagementOpenChange} open={true} /> ) : null} + + {membersChannel ? ( + + { + if (!nextOpen) { + setMembersChannel(null); + } + }} + open={true} + relayUrl={relayUrl} + /> + + ) : null} ); } diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index cfc5ed7bd9..d63ceeb3cd 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -10,6 +10,7 @@ import { Skeleton } from "@/shared/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const MEMORY_LIST_PREVIEW_LIMIT = 3; +type MemorySectionVariant = "cards" | "grouped"; const MEMORY_TRUNCATED_TOOLTIP = "This list may be incomplete — the relay returned the maximum number of memories."; @@ -41,15 +42,17 @@ const MEMORY_DANGLING_REF_TOOLTIP = */ export function MemorySection({ agentPubkey, + variant = "cards", viewerIsOwner, }: { agentPubkey: string; + variant?: MemorySectionVariant; viewerIsOwner: boolean; }): React.ReactElement | null { // Hide entirely for non-owners. if (!viewerIsOwner) return null; - return ; + return ; } export function MemoryRefreshButton({ @@ -92,7 +95,13 @@ export function MemoryRefreshButton({ ); } -function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { +function MemorySectionForOwner({ + agentPubkey, + variant, +}: { + agentPubkey: string; + variant: MemorySectionVariant; +}) { const { query, graph } = useAgentMemoryGraph(agentPubkey); // Order matters here. We want: @@ -107,13 +116,14 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { return (
- {showInitialSkeleton ? : null} + {showInitialSkeleton ? : null} {showInitialError ? ( query.refetch()} retrying={query.isFetching} + variant={variant} /> ) : null} @@ -123,10 +133,17 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { still have prior data on screen. Distinct from the initial error state above. */} {query.isError && !query.isFetching ? ( - query.refetch()} /> + query.refetch()} + variant={variant} + /> ) : null} - + ) : null}
@@ -135,11 +152,11 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { // ── Subviews ──────────────────────────────────────────────────────────────── -function MemorySkeleton() { +function MemorySkeleton({ variant }: { variant: MemorySectionVariant }) { return (
@@ -154,16 +171,21 @@ function MemoryErrorState({ error, onRetry, retrying, + variant, }: { error: unknown; onRetry: () => void; retrying: boolean; + variant: MemorySectionVariant; }) { const message = error instanceof Error ? error.message : String(error ?? "unknown error"); return (
@@ -189,10 +211,19 @@ function MemoryErrorState({ ); } -function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) { +function MemoryStaleErrorBanner({ + onRetry, + variant, +}: { + onRetry: () => void; + variant: MemorySectionVariant; +}) { return (
@@ -211,9 +242,11 @@ function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) { function MemoryGraphView({ graph, truncated, + variant, }: { graph: NonNullable["graph"]>; truncated: boolean; + variant: MemorySectionVariant; }) { const { rootedTree, orphans, dangling } = graph; const [showAllEntries, setShowAllEntries] = React.useState(false); @@ -250,10 +283,13 @@ function MemoryGraphView({ : entries.slice(0, MEMORY_LIST_PREVIEW_LIMIT); return ( -
+
{!core && memories.length > 0 ? (

No core memory yet — agent @@ -261,12 +297,18 @@ function MemoryGraphView({

) : null} -
+
{visibleEntries.map((entry) => ( ))}
@@ -276,14 +318,22 @@ function MemoryGraphView({ count={entries.length} onClick={() => setShowAllEntries(true)} truncated={truncated} + variant={variant} /> ) : null} - {truncated && !hasMoreEntries ? : null} + {truncated && !hasMoreEntries ? ( + + ) : null} {hasMoreEntries && showAllEntries ? ( + ) : channel.channelType !== "dm" ? ( +
+

+ {channel.name} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ ) : null}
); } -export function ChannelQuickAction({ - active, - disabled, - icon: Icon, - label, - onClick, +export function FieldGroup({ + children, + description, testId, + title, }: { - active?: boolean; - disabled?: boolean; - icon: LucideIcon; - label: string; - onClick: () => void; + children: React.ReactNode; + description?: React.ReactNode; testId?: string; + title?: React.ReactNode; }) { return ( - - ); -} - -export function FieldGroup({ children }: { children: React.ReactNode }) { - return ( -
{children}
+ + {children} + ); } @@ -114,26 +137,51 @@ export function getMarkdownPreviewText(content: string) { .join(" "); } +function truncateIdentifier(value: string) { + if (value.length <= 12) return value; + return `${value.slice(0, 8)}…${value.slice(-4)}`; +} + export function CopyFieldRow({ icon: Icon, label, value, testId, }: { - icon: LucideIcon; + icon?: LucideIcon; label: string; value: string; testId?: string; }) { + const [copied, setCopied] = React.useState(false); + const resetTimerRef = React.useRef(null); + + React.useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + async function handleCopy() { await writeTextToClipboard(value); + setCopied(true); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 1_500); toast.success(`Copied ${label.toLowerCase()}`); } return ( ); } @@ -160,73 +235,203 @@ export function CopyFieldRow({ export function InfoFieldRow({ icon: Icon, label, + multiline = false, + onClick, + trailing, value, testId, }: { - icon: LucideIcon; + icon?: LucideIcon; label: string; + multiline?: boolean; + onClick?: () => void; + trailing?: React.ReactNode; value: string; testId?: string; }) { - return ( -
- - - + const content = ( + <> + {Icon ? ( + + ) : null} - + {label} - + {value} -
+ {trailing} + ); -} -export function NarrativeGroup({ children }: { children: React.ReactNode }) { + if (onClick) { + return ( + + ); + } + return ( -
{children}
+
+ {content} +
); } -export function NarrativeField({ +export function EditableInfoFieldRow({ + editTestId, icon: Icon, label, + multiline = false, + onEdit, value, testId, }: { - icon: LucideIcon; + editTestId?: string; + icon?: LucideIcon; label: string; + multiline?: boolean; + onEdit?: () => void; value: string; testId: string; }) { - return ( -
- - - - - + const content = ( + <> + {Icon ? ( + + ) : null} + + {label} - + {value} + {onEdit ? ( + + ) : null} + + ); + + if (onEdit) { + return ( + + ); + } + + return ( +
+ {content}
); } +type ActionFieldRowProps = { + destructive?: boolean; + description?: string; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick?: () => void; + testId: string; +}; + +export const ActionFieldRow = React.forwardRef< + HTMLButtonElement, + ActionFieldRowProps +>(function ActionFieldRow( + { + destructive = false, + description, + disabled, + icon: Icon, + label, + onClick, + testId, + ...triggerProps + }, + ref, +) { + return ( + + ); +}); + export function IngressRow({ description, + helpText, icon: Icon, label, onClick, @@ -234,6 +439,7 @@ export function IngressRow({ trailing, }: { description?: string; + helpText?: string; icon: LucideIcon; label: string; onClick: () => void; @@ -241,29 +447,51 @@ export function IngressRow({ trailing?: string; }) { return ( - + + + {helpText} + + + ) : null} +
+ {description ? ( + + {description} + + ) : null}
- {description ? ( - - {description} + {trailing ? ( + + {trailing} ) : null} - - {trailing ? ( - {trailing} - ) : null} - - + +
+ ); } diff --git a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx new file mode 100644 index 0000000000..a9662bbf04 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; + +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MAX_VISIBLE_AVATARS = 3; + +export function ChannelMemberAvatarStack({ + currentPubkey, + members, +}: { + currentPubkey?: string; + members: ChannelMember[]; +}) { + const visibleMembers = members.slice(0, MAX_VISIBLE_AVATARS); + const visiblePubkeys = React.useMemo( + () => members.slice(0, MAX_VISIBLE_AVATARS).map((member) => member.pubkey), + [members], + ); + const profilesQuery = useUsersBatchQuery(visiblePubkeys); + const profiles = profilesQuery.data?.profiles; + const overflowCount = members.length - visibleMembers.length; + const stackItemCount = visibleMembers.length + (overflowCount > 0 ? 1 : 0); + + if (members.length === 0) { + return null; + } + + return ( +
+ {visibleMembers.map((member, index) => { + const normalizedPubkey = normalizePubkey(member.pubkey); + const profile = profiles?.[normalizedPubkey]; + const label = resolveUserLabel({ + currentPubkey, + fallbackName: member.displayName, + profiles, + pubkey: member.pubkey, + }); + + return ( + 0 ? "-ml-2" : ""} + data-testid="channel-management-member-avatar" + key={normalizedPubkey} + style={{ zIndex: index + 1 }} + > + + + ); + })} + {overflowCount > 0 ? ( + + +{overflowCount} + + ) : null} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 28f3f8aae7..8fc7cfaf51 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -762,6 +762,7 @@ export const ChannelPane = React.memo(function ChannelPane({ key="channel-management-panel" onChannelManagementDeleted={onChannelManagementDeleted} onCloseChannelManagement={onCloseChannelManagement} + onOpenMembers={onOpenMembers} onResetThreadPanelWidth={onResetThreadPanelWidth} onThreadPanelResizeStart={onThreadPanelResizeStart} threadPanelWidthPx={threadPanelWidthPx} diff --git a/desktop/src/features/channels/ui/channelFormStyles.ts b/desktop/src/features/channels/ui/channelFormStyles.ts index 60e4053901..df07001293 100644 --- a/desktop/src/features/channels/ui/channelFormStyles.ts +++ b/desktop/src/features/channels/ui/channelFormStyles.ts @@ -2,4 +2,4 @@ export const CHANNEL_FORM_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; export const CHANNEL_FORM_FIELD_CONTROL_CLASS = - "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; + "border-0 bg-transparent text-foreground shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; diff --git a/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx b/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx new file mode 100644 index 0000000000..88df346af4 --- /dev/null +++ b/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { Channel } from "@/shared/api/types"; + +const MembersSidebar = React.lazy(async () => { + const module = await import("@/features/channels/ui/MembersSidebar"); + return { default: module.MembersSidebar }; +}); + +export function HomeMembersSidebarOverlay({ + channel, + currentPubkey, + onClose, +}: { + channel: Channel | null; + currentPubkey?: string; + onClose: () => void; +}) { + const { activeCommunity } = useCommunities(); + + if (!channel) return null; + + return ( + + { + if (!nextOpen) onClose(); + }} + open={true} + relayUrl={activeCommunity?.relayUrl} + /> + + ); +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 4ffbbaddc5..3b7bff44ea 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -61,7 +61,7 @@ import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; -import type { HomeFeedResponse } from "@/shared/api/types"; +import type { Channel, HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; @@ -72,6 +72,7 @@ import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/Aux import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; +import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; const INBOX_SEARCH_KEYS = [ "item", @@ -167,6 +168,9 @@ export function HomeView({ const [managedChannelId, setManagedChannelId] = React.useState( null, ); + const [membersChannel, setMembersChannel] = React.useState( + null, + ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); const openDm = openDmMutation.mutateAsync; @@ -972,6 +976,7 @@ export function HomeView({ channel={managedChannel} currentPubkey={currentPubkey} layout="split" + onOpenMembers={() => setMembersChannel(managedChannel)} onOpenChange={(nextOpen) => { if (!nextOpen) { setManagedChannelId(null); @@ -983,6 +988,11 @@ export function HomeView({ ) : null}
+ setMembersChannel(null)} + /> ); } diff --git a/desktop/src/features/profile/ui/ProfileTabContentTransition.tsx b/desktop/src/features/profile/ui/ProfileTabContentTransition.tsx new file mode 100644 index 0000000000..b5174251dc --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileTabContentTransition.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { + AnimatePresence, + motion, + type Variants, + useReducedMotion, +} from "motion/react"; + +import type { ProfilePanelTab } from "@/features/profile/ui/UserProfilePanelUtils"; +import { cn } from "@/shared/lib/cn"; + +type TabTransitionDirection = -1 | 0 | 1; + +type TabTransitionContext = { + direction: TabTransitionDirection; + reduceMotion: boolean; +}; + +const TAB_CONTENT_OFFSET_PX = 28; + +const tabContentVariants: Variants = { + enter: ({ direction, reduceMotion }: TabTransitionContext) => ({ + opacity: reduceMotion ? 1 : 0.72, + pointerEvents: "auto", + transform: reduceMotion + ? "translateX(0px)" + : `translateX(${direction * TAB_CONTENT_OFFSET_PX}px)`, + }), + center: { + opacity: 1, + pointerEvents: "auto", + transform: "translateX(0px)", + }, + exit: ({ direction, reduceMotion }: TabTransitionContext) => ({ + opacity: reduceMotion ? 1 : 0, + pointerEvents: "none", + transform: reduceMotion + ? "translateX(0px)" + : `translateX(${-direction * TAB_CONTENT_OFFSET_PX}px)`, + }), +}; + +export function ProfileTabContentTransition({ + activeTab, + children, + className, + tabs, +}: { + activeTab: ProfilePanelTab; + children: React.ReactNode; + className?: string; + tabs: ProfilePanelTab[]; +}) { + const reduceMotion = useReducedMotion() ?? false; + const [lastTransition, setLastTransition] = React.useState<{ + direction: TabTransitionDirection; + tab: ProfilePanelTab; + }>({ direction: 0, tab: activeTab }); + const previousIndex = tabs.indexOf(lastTransition.tab); + const activeIndex = tabs.indexOf(activeTab); + const direction: TabTransitionDirection = + lastTransition.tab === activeTab + ? lastTransition.direction + : previousIndex < 0 || activeIndex < 0 || previousIndex === activeIndex + ? 0 + : activeIndex > previousIndex + ? 1 + : -1; + const transitionContext: TabTransitionContext = { + direction, + reduceMotion, + }; + + React.useLayoutEffect(() => { + if (lastTransition.tab !== activeTab) { + setLastTransition({ direction, tab: activeTab }); + } + }, [activeTab, direction, lastTransition.tab]); + + return ( +
0 ? "forward" : "backward" + } + > + + + {children} + + +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index 81db3405b2..5919a16e29 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -6,23 +6,12 @@ import { Download, Power, Settings, - Trash2, } from "lucide-react"; import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog"; import type { ManagedAgent } from "@/shared/api/types"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; -import { Button, buttonVariants } from "@/shared/ui/button"; +import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -37,7 +26,6 @@ export function UserProfileAgentSettingsMenu({ isPending, isBot = false, managedAgent, - onDelete, onDuplicatePersona, onExportPersona, onToggleAutoStart, @@ -47,14 +35,12 @@ export function UserProfileAgentSettingsMenu({ isPending: boolean; isBot?: boolean; managedAgent?: ManagedAgent; - onDelete?: () => void; onDuplicatePersona?: () => void; onExportPersona?: () => void; onToggleAutoStart?: () => void; personaActionKey?: string; }) { const [archiveConfirmOpen, setArchiveConfirmOpen] = React.useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false); const actionKey = managedAgent?.pubkey ?? "persona-draft"; const personaKey = personaActionKey ?? actionKey; const canToggleAutoStart = @@ -66,11 +52,8 @@ export function UserProfileAgentSettingsMenu({ const hasArchiveAction = archiveActions?.canArchive === true && archiveActions.isArchived !== undefined; - const shouldConfirmAgentDelete = - managedAgent !== undefined && onDelete !== undefined; - const hasManageActions = hasArchiveAction || Boolean(onDelete); const hasActions = - canToggleAutoStart || hasPrimaryActions || hasManageActions; + canToggleAutoStart || hasPrimaryActions || hasArchiveAction; if (!hasActions) { return null; @@ -142,7 +125,7 @@ export function UserProfileAgentSettingsMenu({ Export ) : null} - {hasManageActions && (canToggleAutoStart || hasPrimaryActions) ? ( + {hasArchiveAction && (canToggleAutoStart || hasPrimaryActions) ? ( ) : null} {hasArchiveAction && archiveActions ? ( @@ -166,24 +149,6 @@ export function UserProfileAgentSettingsMenu({ ) ) : null} - {onDelete && hasArchiveAction ? : null} - {onDelete ? ( - { - if (shouldConfirmAgentDelete) { - setDeleteConfirmOpen(true); - return; - } - onDelete(); - }} - > - - Delete agent - - ) : null} {hasArchiveAction && archiveActions ? ( @@ -198,32 +163,17 @@ export function UserProfileAgentSettingsMenu({ open={archiveConfirmOpen} /> ) : null} - {shouldConfirmAgentDelete ? ( - { - setDeleteConfirmOpen(false); - onDelete(); - }} - onOpenChange={setDeleteConfirmOpen} - open={deleteConfirmOpen} - /> - ) : null} ); } export function UserProfileAgentSettingsMenuSlot({ archiveActions, - canDeletePersona, canInstantiateAgent, canManagePersona, isAgentActionPending, isBot, managedAgent, - onDeleteAgent, - onDeletePersona, onDuplicatePersona, onExportPersona, onToggleAutoStart, @@ -231,14 +181,11 @@ export function UserProfileAgentSettingsMenuSlot({ viewerIsOwner, }: { archiveActions: IdentityArchiveActions; - canDeletePersona: boolean; canInstantiateAgent: boolean; canManagePersona: boolean; isAgentActionPending: boolean; isBot: boolean; managedAgent?: ManagedAgent; - onDeleteAgent: () => void; - onDeletePersona: () => void; onDuplicatePersona: () => void; onExportPersona: () => void; onToggleAutoStart: () => void; @@ -250,11 +197,12 @@ export function UserProfileAgentSettingsMenuSlot({ const settingsActionPending = isAgentActionPending || archiveActions.isPending; const sharedProps = { - archiveActions: canShowArchiveAction ? archiveActions : undefined, + archiveActions: !isBot && canShowArchiveAction ? archiveActions : undefined, isBot, isPending: settingsActionPending, - onDuplicatePersona: canManagePersona ? onDuplicatePersona : undefined, - onExportPersona: canManagePersona ? onExportPersona : undefined, + onDuplicatePersona: + !isBot && canManagePersona ? onDuplicatePersona : undefined, + onExportPersona: !isBot && canManagePersona ? onExportPersona : undefined, personaActionKey, }; @@ -263,22 +211,16 @@ export function UserProfileAgentSettingsMenuSlot({ ); } if (canInstantiateAgent) { - return ( - - ); + return ; } - if (canShowArchiveAction) { + if (canShowArchiveAction && !isBot) { return ( void; - onOpenChange: (open: boolean) => void; - open: boolean; -}) { - const isProviderAgent = agent.backend.type === "provider"; - - return ( - - - - Delete this agent? - - Deleting this agent stops and removes the agent from this community. - - -
    -
  • Removes the local management record and saved agent key
  • -
  • Removes the agent from every channel it belongs to
  • -
  • - Archives the agent's identity on the relay so it no longer - appears in member lists or mention suggestions -
  • -
  • - {isProviderAgent - ? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management." - : "Stops any local agent process before deleting the record"} -
  • -
-

- You can also archive this agent from the profile settings menu if you - want to hide the agent instead of removing it. -

- - - - - - {isPending ? "Deleting..." : "Delete agent"} - - -
-
- ); -} diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx new file mode 100644 index 0000000000..8c6b4138cd --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -0,0 +1,283 @@ +import * as React from "react"; +import { + Archive, + ArchiveRestore, + CopyPlus, + Download, + Trash2, + type LucideIcon, +} from "lucide-react"; + +import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; +import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog"; +import type { ManagedAgent } from "@/shared/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button, buttonVariants } from "@/shared/ui/button"; +import { PanelSectionGroup } from "@/shared/ui/PanelSectionGroup"; + +export function UserProfileAgentManagementRows({ + archiveActions, + canArchiveAgent, + canDeleteAgent, + isDeletePending, + managedAgent, + onDeleteAgent, + onDuplicateAgent, + onExportAgent, +}: { + archiveActions: IdentityArchiveActions; + canArchiveAgent: boolean; + canDeleteAgent: boolean; + isDeletePending: boolean; + managedAgent?: ManagedAgent; + onDeleteAgent: () => void; + onDuplicateAgent?: () => void; + onExportAgent?: () => void; +}) { + if ( + !onDuplicateAgent && + !onExportAgent && + !canArchiveAgent && + !canDeleteAgent + ) { + return null; + } + + return ( + + {onDuplicateAgent ? ( + + ) : null} + {onExportAgent ? ( + + ) : null} + {canArchiveAgent ? ( + + ) : null} + {canDeleteAgent ? ( + + ) : null} + + ); +} + +function ProfileAgentActionRow({ + destructive = false, + disabled = false, + icon: Icon, + label, + onClick, + testId, +}: { + destructive?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + ); +} + +function ProfileArchiveAgentRow({ + archiveActions, +}: { + archiveActions: IdentityArchiveActions; +}) { + const [confirmOpen, setConfirmOpen] = React.useState(false); + const isArchived = archiveActions.isArchived === true; + const Icon = isArchived ? ArchiveRestore : Archive; + const label = archiveActions.isPending + ? isArchived + ? "Unarchiving…" + : "Archiving…" + : isArchived + ? "Unarchive agent" + : "Archive agent"; + + return ( + <> + { + if (isArchived) { + archiveActions.unarchive(); + return; + } + setConfirmOpen(true); + }} + testId={ + isArchived + ? "user-profile-unarchive-agent-row" + : "user-profile-archive-agent-row" + } + /> + { + archiveActions.archive(); + setConfirmOpen(false); + }} + onOpenChange={setConfirmOpen} + open={confirmOpen} + /> + + ); +} + +function ProfileDeleteAgentRow({ + isPending, + managedAgent, + onDelete, +}: { + isPending: boolean; + managedAgent?: ManagedAgent; + onDelete: () => void; +}) { + const [confirmOpen, setConfirmOpen] = React.useState(false); + + return ( + <> + { + if (managedAgent) { + setConfirmOpen(true); + return; + } + onDelete(); + }} + testId="user-profile-delete-agent-row" + /> + {managedAgent ? ( + { + setConfirmOpen(false); + onDelete(); + }} + onOpenChange={setConfirmOpen} + open={confirmOpen} + /> + ) : null} + + ); +} + +function AgentDeleteConfirmDialog({ + agent, + isPending, + onConfirm, + onOpenChange, + open, +}: { + agent: ManagedAgent; + isPending: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const isProviderAgent = agent.backend.type === "provider"; + + return ( + + + + Delete this agent? + + Deleting this agent stops and removes the agent from this community. + + +
    +
  • Removes the local management record and saved agent key
  • +
  • Removes the agent from every channel it belongs to
  • +
  • + Archives the agent's identity on the relay so it no longer + appears in member lists or mention suggestions +
  • +
  • + {isProviderAgent + ? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management." + : "Stops any local agent process before deleting the record"} +
  • +
+

+ Archive this agent if you want to hide it instead of removing it. +

+ + + + + + {isPending ? "Deleting…" : "Delete agent"} + + +
+
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx b/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx new file mode 100644 index 0000000000..b1f4f54eed --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx @@ -0,0 +1,34 @@ +import { AgentDialog } from "@/features/agents/ui/AgentDialog"; +import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; +import type { ManagedAgent } from "@/shared/api/types"; + +export function UserProfileEditAgentDialog({ + agent, + canEdit, + initialFocus, + onEditLinkedPersona, + onOpenChange, + open, +}: { + agent: ManagedAgent | undefined; + canEdit: boolean; + initialFocus: EditAgentFocusTarget | undefined; + onEditLinkedPersona: (() => void) | undefined; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + if (!canEdit || !agent) { + return null; + } + + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd008..91040a28e2 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -2,10 +2,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { - useAgentMemoryQuery, - useIsManagedAgent, -} from "@/features/agent-memory/hooks"; +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { type AttachManagedAgentToChannelResult, useAcpRuntimesQuery, @@ -33,13 +30,7 @@ import { resolveStartRuntimeForDefinition, } from "@/features/agents/lib/instanceInputForDefinition"; import { describeLogFile } from "@/features/agents/ui/agentUi"; -import { AgentDialog } from "@/features/agents/ui/AgentDialog"; import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions"; -import { - consumePendingOpenEditAgent, - type EditAgentFocusTarget, - subscribeOpenEditAgent, -} from "@/features/agents/openEditAgentEvent"; import { duplicatePersonaDialogState, editPersonaDialogState, @@ -59,13 +50,15 @@ import { import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; import { - AgentInfoFocusedView, AgentInstructionsFocusedView, + ProfileSummaryView, +} from "@/features/profile/ui/UserProfilePanelSections"; +import { + AgentInfoFocusedView, ChannelsFocusedView, DiagnosticsFocusedView, MemoryFocusedView, - ProfileSummaryView, -} from "@/features/profile/ui/UserProfilePanelSections"; +} from "@/features/profile/ui/UserProfilePanelFocusedViews"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; @@ -86,7 +79,7 @@ import { type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; -import { useProfileDmAction } from "@/features/profile/ui/useProfileDmAction"; +import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -101,6 +94,8 @@ import type { } from "@/shared/api/types"; import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent"; +import { UserProfileEditAgentDialog } from "@/features/profile/ui/UserProfileEditAgentDialog"; +import { useProfileEditAgentRequest } from "@/features/profile/ui/useProfileEditAgentRequest"; export type { ProfilePanelTab, ProfilePanelView }; export function UserProfilePanel({ @@ -154,28 +149,27 @@ export function UserProfilePanel({ }, [onTabChange], ); - const [editAgentOpen, setEditAgentOpen] = React.useState(false); - const [editAgentFocus, setEditAgentFocus] = React.useState< - EditAgentFocusTarget | undefined - >(undefined); - - // Open the Edit Agent dialog when `requestOpenEditAgent(pubkey)` fires from - // a card or other non-panel surface (e.g. `ConfigNudgeCard`). Mirrors the - // `subscribeOpenCreateAgent` pattern in AgentsView. - React.useEffect(() => { - if (!pubkey) return; - // Consume any pending request that arrived before this panel mounted. - const pending = consumePendingOpenEditAgent(pubkey); - if (pending !== false) { - setEditAgentFocus(pending === true ? undefined : pending); - setEditAgentOpen(true); - } - // Subscribe for events that arrive while the panel is mounted. - return subscribeOpenEditAgent(pubkey, (focus) => { - setEditAgentFocus(focus); - setEditAgentOpen(true); - }); - }, [pubkey]); + const [stickyChrome, setStickyChrome] = React.useState({ + active: false, + height: 0, + }); + const handleStickyChromeChange = React.useCallback( + (nextState: { active: boolean; height: number }) => { + setStickyChrome((currentState) => + currentState.active === nextState.active && + currentState.height === nextState.height + ? currentState + : nextState, + ); + }, + [], + ); + const { + focus: editAgentFocus, + open: editAgentOpen, + setFocus: setEditAgentFocus, + setOpen: setEditAgentOpen, + } = useProfileEditAgentRequest(pubkey); const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); @@ -325,15 +319,8 @@ export function UserProfilePanel({ }), [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - // Observer ingestion (frame decryption + derived active-turn liveness) is - // owner-global — mounted once in AppShell via useAgentObserverIngestion — - // covering both locally managed agents and declared-owned relay agents. - const canEditAgent = - isOwner === true && - (managedAgent !== undefined || resolvedPersona !== undefined); - const memoryQuery = useAgentMemoryQuery(effectivePubkey, { - enabled: viewerIsOwner && Boolean(effectivePubkey), - }); + // Observer ingestion is owner-global across local and declared-owned agents. + const canEditAgent = Boolean(isOwner && (managedAgent ?? resolvedPersona)); const isSelf = currentPubkey !== undefined && pubkeyLower.length > 0 && @@ -395,10 +382,22 @@ export function UserProfilePanel({ setView("summary", { replace: true }); setTab("info", { replace: true }); }, [setTab, setView, targetKey]); - const { handleMessage, isOpeningDm } = useProfileDmAction({ + const { + canHuddle, + canMessage, + canWave, + handleHuddle, + handleMessage, + handleWave, + isStartingHuddle, + pendingAction, + } = useProfileInteractionActions({ effectivePubkey, + enabled: onOpenDm !== undefined, + isBot, + isSelf, onClose, - onOpenDm, + viewerIsOwner, }); const handleEditAgent = React.useCallback(() => { @@ -407,7 +406,7 @@ export function UserProfilePanel({ return; } setEditAgentOpen(true); - }, [resolvedPersona]); + }, [resolvedPersona, setEditAgentOpen]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ @@ -707,31 +706,27 @@ export function UserProfilePanel({ : null; const ownerProfilePubkey = ownerPubkey ?? (isOwner === true ? (currentPubkey ?? null) : null); - const ownerAvatarProfile = ownerPubkey - ? ownerProfileQuery.data - : currentProfileQuery.data; - const memoryCount = - memoryQuery.data && - (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; const agentInstruction = resolveAgentInstruction( managedAgent, resolvedPersona, ); const canManagePersona = isOwner === true && resolvedPersona !== undefined; - const canEditPersona = canManagePersona; const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const canDeleteProfileAgent = + isBot && + ((viewerIsOwner && managedAgent !== undefined) || + (canInstantiateAgent && canDeletePersona)); + const handleDeleteProfileAgent = + viewerIsOwner && managedAgent ? handleDeleteAgent : handleDeletePersona; const archiveActions = useIdentityArchive(effectivePubkey); - const agentSettingsMenu = ( + const agentSettingsMenu = isBot ? null : ( setView("summary"), + onEditAgent: canEditAgent ? handleEditAgent : undefined, view, viewerIsOwner, }, @@ -783,10 +778,12 @@ export function UserProfilePanel({ ? "flex flex-col overflow-hidden" : "overflow-y-auto", )} + data-testid="user-profile-scroll-body" > {view === "summary" ? ( setAddToChannelOpen(true)} + onDeleteAgent={handleDeleteProfileAgent} + onDuplicateAgent={ + isBot && canManagePersona ? handleDuplicatePersona : undefined + } + onExportAgent={ + isBot && canManagePersona ? handleExportPersona : undefined + } onOpenInstance={(instancePubkey) => onOpenProfile?.(instancePubkey)} onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} - onOpenInstructions={() => setView("instructions")} + onStickyChromeChange={handleStickyChromeChange} onTabChange={setTab} - onOpenDm={onOpenDm} - onCreateCard={ - canManagePersona && resolvedPersona - ? () => - setCardMintTarget({ - // Prefer the live instance pubkey; fall back to the - // persona/definition id (same resolution as export). - id: managedAgent?.pubkey ?? resolvedPersona.id, - name: resolvedPersona.displayName, - // Locking needs an instance keypair to encrypt to. - canLock: Boolean(managedAgent?.pubkey), - }) - : undefined - } presenceStatus={presenceStatus} profile={profile} pubkey={effectivePubkey} @@ -905,28 +899,27 @@ export function UserProfilePanel({ ) : null} ); - const editAgentDialog = - canEditAgent && managedAgent ? ( - { - setEditAgentOpen(false); - setEditAgentFocus(undefined); - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - } - : undefined - } - onOpenChange={(next) => { - setEditAgentOpen(next); - if (!next) setEditAgentFocus(undefined); - }} - open={editAgentOpen} - /> - ) : null; + const editAgentDialog = ( + { + setEditAgentOpen(false); + setEditAgentFocus(undefined); + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + } + : undefined + } + onOpenChange={(next) => { + setEditAgentOpen(next); + if (!next) setEditAgentFocus(undefined); + }} + open={editAgentOpen} + /> + ); const addAgentToChannelDialog = managedAgent ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx index 844de528e2..1da2e597dd 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx @@ -116,11 +116,11 @@ export function AgentInstructionRow({ trimmedInstruction.length > 0 && onOpenInstructions !== undefined; const rowContent = ( <> - - - +
-
Instructions
+
+ Agent instructions +
{trimmedInstruction ? ( canOpenInstructions ? ( void; testId?: string; @@ -82,7 +83,6 @@ export function useProfileFieldBuckets({ isOwner, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -98,7 +98,6 @@ export function useProfileFieldBuckets({ isOwner: boolean | undefined; managedAgent: ManagedAgent | undefined; onOpenProfile?: (pubkey: string) => void; - ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; ownerProfilePubkey: string | null; @@ -118,7 +117,6 @@ export function useProfileFieldBuckets({ includeOperationalFields: isOwner === true, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -136,7 +134,6 @@ export function useProfileFieldBuckets({ isOwner, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -167,10 +164,17 @@ export function buildPublicFields({ if (pubkey) { fields.push({ + copyValue: pubkey, displayValue: truncatePubkey(pubkey), - displayNode: , - icon: Fingerprint, + displayNode: ( + + ), label: "Public key", + testId: "user-profile-public-key", }); } @@ -220,7 +224,6 @@ export function buildOwnerFields({ includeOperationalFields, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -233,7 +236,6 @@ export function buildOwnerFields({ includeOperationalFields: boolean; managedAgent: ManagedAgent | undefined; onOpenProfile?: (pubkey: string) => void; - ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; ownerProfilePubkey: string | null; @@ -256,18 +258,6 @@ export function buildOwnerFields({ : null; const ownerClickable = Boolean(onOpenProfile && ownerProfilePubkey); - const ownerContent = ( - <> - - {ownerDisplayName} - - ); if (ownerDisplayName) { fields.push({ @@ -275,12 +265,7 @@ export function buildOwnerFields({ ? undefined : (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, - displayNode: ( - - {ownerContent} - - ), - icon: UserRound, + displayNode: {ownerDisplayName}, label: "Managed by", onClick: ownerClickable && ownerProfilePubkey @@ -335,8 +320,10 @@ export function buildOwnerFields({ .replace(/\b\w/g, (char: string) => char.toUpperCase()), displayNode: ( ), @@ -439,52 +426,114 @@ function orderProfileFields(fields: ProfileField[]) { ]; } -export function ProfileFieldRows({ fields }: { fields: ProfileField[] }) { +export function ProfileFieldRows({ + fields, + variant = "default", +}: { + fields: ProfileField[]; + variant?: "default" | "runtime"; +}) { return ( <> {orderProfileFields(fields).map((field) => ( - + ))} ); } -export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { +export function ProfileSectionGroup({ + children, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + return ( + +
{children}
+
+ ); +} + +export function ProfileFieldGroup({ + fields, + title, +}: { + fields: ProfileField[]; + title?: string; +}) { return ( -
-
- -
-
+ + + ); } -function ProfileFieldRow({ field }: { field: ProfileField }) { +function ProfileFieldRow({ + field, + variant, +}: { + field: ProfileField; + variant: "default" | "runtime"; +}) { const Icon = field.icon; const isCopyable = Boolean(field.copyValue); const isActionable = Boolean(field.onClick); + const isTrailingDisplay = + variant === "runtime" && field.label === "Status" && field.displayNode; + const { copied, copy } = useCopyFeedback({ + label: field.label, + value: field.copyValue ?? "", + }); const content = ( <> - - - + {variant === "default" && Icon ? ( + + ) : null} - + {field.label} - - {field.displayNode ?? field.displayValue} - + {!isTrailingDisplay ? ( + + {field.displayNode ?? field.displayValue} + + ) : null} + {isTrailingDisplay ? field.displayNode : null} {field.trailingNode} {isActionable ? ( - + ) : isCopyable ? ( - + ) : null} ); @@ -493,7 +542,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) { return ( + + ))} + + )} + +
+ ); +} + +export function AgentInfoFocusedView({ + metadataFields, +}: { + metadataFields: ProfileField[]; +}) { + if (metadataFields.length === 0) { + return null; + } + + return ( +
+ +
+ ); +} + +export function DiagnosticsFocusedView({ + canOpenAgentLogs, + fields, + logContent, + logError, + logLoading, + managedAgent, +}: { + canOpenAgentLogs: boolean; + fields: ProfileField[]; + logContent: string | null; + logError: Error | null; + logLoading: boolean; + managedAgent: ManagedAgent | undefined; +}) { + const hasLog = canOpenAgentLogs && managedAgent !== undefined; + const lastErrorField = fields.find((field) => field.label === "Last error"); + const detailFields = fields.filter( + (field) => field.label !== "Last error" && field.label !== "Status", + ); + + if (!lastErrorField && detailFields.length === 0 && !hasLog) { + return null; + } + + return ( +
+ {lastErrorField ? ( + + +
+ Last error + + {lastErrorField.displayValue} + +
+
+ ) : null} + {detailFields.length > 0 ? ( + + ) : null} + {hasLog ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx index 81d1860448..11a744ee8a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx @@ -1,7 +1,11 @@ import type * as React from "react"; -import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel"; -import { AuxiliaryPanelHeader } from "@/shared/layout/AuxiliaryPanel"; +import { + AUXILIARY_PANEL_DEFAULT_SURFACE_CLASS, + AuxiliaryPanel, + AuxiliaryPanelHeader, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; type UserProfilePanelFrameProps = { addAgentToChannelDialog: React.ReactNode; @@ -18,6 +22,9 @@ type UserProfilePanelFrameProps = { personaDialogs: React.ReactNode; profileBody: React.ReactNode; splitPaneClamp: boolean; + stickyChromeActive: boolean; + stickyChromeEnabled: boolean; + stickyChromeHeight: number; widthPx: number; transparentChrome?: boolean; }; @@ -37,12 +44,16 @@ export function UserProfilePanelFrame({ personaDialogs, profileBody, splitPaneClamp, + stickyChromeActive, + stickyChromeEnabled, + stickyChromeHeight, widthPx, transparentChrome = false, }: UserProfilePanelFrameProps) { return ( - {headerLeftContent} - {headerActions} - + <> +