diff --git a/desktop/.env.e2e b/desktop/.env.e2e new file mode 100644 index 00000000000..a323e18f6c7 --- /dev/null +++ b/desktop/.env.e2e @@ -0,0 +1 @@ +VITE_BUZZ_BESTIE=1 diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 701c767b329..674300ff73e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", + "**/bestie.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/message-copy-link.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index eacef621fb3..296123f470d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,14 +6,16 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ + bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -1087,6 +1089,20 @@ pub async fn stop_managed_agent( // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. +fn run_managed_agent_deletion( + base_dir: &std::path::Path, + pubkey: &str, + records: &mut Vec, + delete: impl FnOnce(&mut Vec) -> Result, +) -> Result { + recover_pending_assignment_cleanup(base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; + with_agent_assignments_cleared(base_dir, pubkey, || delete(records)) +} + #[tauri::command] pub async fn delete_managed_agent( pubkey: String, @@ -1102,6 +1118,12 @@ pub async fn delete_managed_agent( .lock() .map_err(|error| error.to_string())?; let mut records = load_managed_agents(&app)?; + let base_dir = managed_agents_base_dir(&app)?; + recover_pending_assignment_cleanup(&base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() @@ -1135,16 +1157,17 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - state.clear_agent_session_caches(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { + if !records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records)?; + run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| { + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + state.clear_agent_session_caches(&pubkey); + records.retain(|record| record.pubkey != pubkey); + save_managed_agents(&app, records) + })?; crate::managed_agents::delete_agent_key(&pubkey); // Tombstone after confirmed removal (inside lock; every published // agent tombstones). The NIP-IA kind:9035 archive request — which diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index ef71321bedf..59e04b09ff0 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -188,6 +188,45 @@ fn deploy_resolver_inherits_global_when_definition_blank() { ); } +#[test] +fn production_delete_orchestration_restores_bestie_when_agent_save_fails() { + use crate::managed_agents::{ + bestie_assignment::{assignment_matches, replace_assignment}, + retention::open_retention_db, + }; + + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + std::fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let db_path = retention_dir.join("owner.db"); + let pubkey = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("open assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("seed assignment: {error}")); + let mut record = bare_agent_record(None, None, None); + record.pubkey.clone_from(&pubkey); + let mut records = vec![record]; + + let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| { + Err::<(), _>("injected managed-agent save failure".to_string()) + }); + + assert_eq!( + result, + Err("injected managed-agent save failure".to_string()) + ); + assert!(assignment_matches( + &open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("reopen assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); +} + /// Deploy resolver falls back to global when both definition and record have none. #[test] fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { diff --git a/desktop/src-tauri/src/commands/bestie.rs b/desktop/src-tauri/src/commands/bestie.rs new file mode 100644 index 00000000000..19f21186258 --- /dev/null +++ b/desktop/src-tauri/src/commands/bestie.rs @@ -0,0 +1,218 @@ +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + bestie_assignment::{ + assignment_matches, clear_assignment, get_assignment, + recover_pending_assignment_cleanup, replace_assignment, BestieAssignment, + }, + load_managed_agents, managed_agents_base_dir, + retention::{active_retention_scope, open_retention_db, RetentionScope}, + BackendKind, ManagedAgentRecord, + }, + models::ChannelInfo, +}; + +fn canonical_relay(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string()) +} + +fn assert_expected_scope( + scope: &RetentionScope, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, +) -> Result<(), String> { + if let Some(expected) = expected_relay_url { + if canonical_relay(expected)? != canonical_relay(&scope.relay_url)? { + return Err("active community changed while resolving Bestie".to_string()); + } + } + if let Some(expected) = expected_signer_pubkey { + if expected.trim().to_ascii_lowercase() != scope.owner_keys.public_key().to_hex() { + return Err("active identity changed while resolving Bestie".to_string()); + } + } + Ok(()) +} + +fn validate_agent_pubkey(pubkey: &str) -> Result { + let normalized = pubkey.trim().to_ascii_lowercase(); + if normalized.len() != 64 + || !normalized + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("Bestie agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(normalized) +} + +fn require_eligible_local_agent( + records: &[ManagedAgentRecord], + pubkey: &str, +) -> Result<(), String> { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(pubkey)) + .ok_or_else(|| "assigned Bestie agent no longer exists on this device".to_string())?; + if record.backend != BackendKind::Local { + return Err("only a local managed agent can be your Bestie".to_string()); + } + Ok(()) +} + +fn recover_pending_cleanup(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + }) +} + +#[tauri::command] +pub fn get_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + get_assignment(&conn) +} + +#[tauri::command] +pub fn assign_bestie( + agent_pubkey: String, + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let pubkey = validate_agent_pubkey(&agent_pubkey)?; + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + require_eligible_local_agent(&records, &pubkey)?; + let mut conn = open_retention_db(&scope.db_path)?; + replace_assignment(&mut conn, &pubkey) +} + +#[tauri::command] +pub fn clear_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let mut conn = open_retention_db(&scope.db_path)?; + clear_assignment(&mut conn) +} + +#[tauri::command] +pub async fn resolve_bestie_conversation( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let generation = state.workspace_apply_generation.load(Ordering::Acquire); + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let assignment = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + let assignment = get_assignment(&conn)? + .ok_or_else(|| "choose an agent before opening Bestie".to_string())?; + require_eligible_local_agent(&records, &assignment.agent_pubkey)?; + assignment + }; + + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let channel = super::dms::open_dm_with_scope( + vec![assignment.agent_pubkey.clone()], + Some(&scope.relay_url), + Some(&owner_pubkey), + &state, + ) + .await?; + + if state.workspace_apply_generation.load(Ordering::Acquire) != generation { + return Err("active workspace changed while resolving Bestie".to_string()); + } + let current_scope = active_retention_scope(&app, &state)?; + assert_expected_scope(¤t_scope, Some(&scope.relay_url), Some(&owner_pubkey))?; + let conn = open_retention_db(&scope.db_path)?; + if !assignment_matches(&conn, &assignment.agent_pubkey)? { + return Err("Bestie assignment changed while opening the conversation".to_string()); + } + Ok(channel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_scope_accepts_runtime_equivalences() { + assert_eq!( + canonical_relay(" WSS://LOCALHOST:443/ ") + .unwrap_or_else(|error| panic!("canonical relay: {error}")), + "wss://127.0.0.1" + ); + } + + #[test] + fn pubkeys_are_normalized_and_validated() { + assert_eq!( + validate_agent_pubkey(&"A".repeat(64)) + .unwrap_or_else(|error| panic!("valid pubkey: {error}")), + "a".repeat(64) + ); + assert!(validate_agent_pubkey("short").is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index 5f6ca279802..252068c94d7 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -23,6 +23,21 @@ pub async fn open_dm( expected_relay_url: Option, expected_signer_pubkey: Option, state: State<'_, AppState>, +) -> Result { + open_dm_with_scope( + pubkeys, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + &state, + ) + .await +} + +pub(crate) async fn open_dm_with_scope( + pubkeys: Vec, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + state: &AppState, ) -> Result { // Resolve the relay AND the signing identity once for the open + metadata // read pair. Callers with a captured tenant scope (Projects agent sends) @@ -34,25 +49,22 @@ pub async fn open_dm( // tenant-A DM signed as tenant B's identity — fail closed instead, and // use this exact key snapshot for both the event signature and the // NIP-98 auth of every request in this command. - let api_base_url = crate::relay::relay_api_base_url_with_override(&state); - assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?; + let api_base_url = crate::relay::relay_api_base_url_with_override(state); + assert_expected_relay_scope(expected_relay_url, &api_base_url)?; let keys = state.signing_keys()?; - assert_expected_signer( - expected_signer_pubkey.as_deref(), - &keys.public_key().to_hex(), - )?; + assert_expected_signer(expected_signer_pubkey, &keys.public_key().to_hex())?; // Submit a kind:41010 dm-open event; the relay replies with the channel id // in its OK message payload. let builder = events::build_dm_open(&pubkeys)?; - let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?; + let result = submit_event_at_with_keys(builder, state, &api_base_url, &keys).await?; let ack: OpenDmAck = parse_command_response(&result.message)?; // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` // shape as `get_channel_details` — through the same scope-checked base and // the same pinned identity. let metadata = query_relay_at_with_keys( - &state, + state, &api_base_url, &[serde_json::json!({ "kinds": [39000], diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 324e13e68a5..c8184a01031 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod bestie; mod canvas; mod channel_reconnect_repair; mod channel_templates; @@ -84,6 +85,7 @@ pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; +pub use bestie::*; pub use canvas::*; pub use channel_reconnect_repair::*; pub use channel_templates::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0e4832a702a..12082a2a82e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -621,6 +621,10 @@ pub fn run() { create_channel, ensure_starter_channels, open_dm, + get_bestie_assignment, + assign_bestie, + clear_bestie_assignment, + resolve_bestie_conversation, hide_dm, get_channel_details, get_channel_members, diff --git a/desktop/src-tauri/src/managed_agents/bestie_assignment.rs b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs new file mode 100644 index 00000000000..e823db124b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs @@ -0,0 +1,595 @@ +//! Durable, owner-and-relay-scoped Bestie designation storage. + +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use super::{retention::open_retention_db, storage::atomic_write_json_restricted}; + +const RECOVERY_JOURNAL_FILE: &str = "bestie-assignment-recovery.json"; + +/// The one durable Bestie designation in a retention scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BestieAssignment { + pub agent_pubkey: String, +} + +fn ensure_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS bestie_assignments ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + agent_pubkey TEXT NOT NULL + );", + ) + .map_err(|error| format!("failed to create bestie assignment table: {error}")) +} + +/// Read the designation for the already-scoped retention database. +pub fn get_assignment(conn: &Connection) -> Result, String> { + ensure_table(conn)?; + conn.query_row( + "SELECT agent_pubkey FROM bestie_assignments WHERE singleton = 1", + [], + |row| { + Ok(BestieAssignment { + agent_pubkey: row.get(0)?, + }) + }, + ) + .optional() + .map_err(|error| format!("failed to read bestie assignment: {error}")) +} + +/// Atomically create or replace the one designation in this scope. +pub fn replace_assignment( + conn: &mut Connection, + agent_pubkey: &str, +) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie assignment transaction: {error}"))?; + transaction + .execute( + "INSERT INTO bestie_assignments (singleton, agent_pubkey) + VALUES (1, ?1) + ON CONFLICT(singleton) DO UPDATE SET agent_pubkey = excluded.agent_pubkey", + params![normalized], + ) + .map_err(|error| format!("failed to replace bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie assignment: {error}"))?; + get_assignment(conn)?.ok_or_else(|| "bestie assignment was not persisted".to_string()) +} + +/// Clear the designation without changing or stopping the agent. +pub fn clear_assignment(conn: &mut Connection) -> Result<(), String> { + ensure_table(conn)?; + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie clear transaction: {error}"))?; + transaction + .execute("DELETE FROM bestie_assignments WHERE singleton = 1", []) + .map_err(|error| format!("failed to clear bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie clear: {error}")) +} + +/// Whether the same agent is still designated after an asynchronous operation. +pub fn assignment_matches(conn: &Connection, agent_pubkey: &str) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + Ok(get_assignment(conn)?.is_some_and(|assignment| assignment.agent_pubkey == normalized)) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ScopedAssignment { + agent_pubkey: String, + path: PathBuf, +} + +#[derive(Debug, Deserialize, Serialize)] +struct AssignmentRecoveryJournal { + assignments: Vec, + version: u8, +} + +fn recovery_journal_path(base_dir: &Path) -> PathBuf { + base_dir.join(RECOVERY_JOURNAL_FILE) +} + +fn persist_recovery_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], +) -> Result<(), String> { + fs::create_dir_all(base_dir) + .map_err(|error| format!("failed to create agents directory: {error}"))?; + let payload = serde_json::to_vec_pretty(&AssignmentRecoveryJournal { + assignments: assignments.to_vec(), + version: 1, + }) + .map_err(|error| format!("failed to serialize Bestie recovery journal: {error}"))?; + atomic_write_json_restricted(&recovery_journal_path(base_dir), &payload) + .map_err(|error| format!("failed to persist Bestie recovery journal: {error}")) +} + +fn load_recovery_journal(base_dir: &Path) -> Result, String> { + let path = recovery_journal_path(base_dir); + let payload = match fs::read(&path) { + Ok(payload) => payload, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read Bestie recovery journal {}: {error}", + path.display() + )) + } + }; + let journal: AssignmentRecoveryJournal = serde_json::from_slice(&payload) + .map_err(|error| format!("failed to parse Bestie recovery journal: {error}"))?; + if journal.version != 1 { + return Err(format!( + "unsupported Bestie recovery journal version {}", + journal.version + )); + } + let retention_dir = base_dir.join("retention"); + for assignment in &journal.assignments { + if assignment.path.parent() != Some(retention_dir.as_path()) + || assignment.path.extension().and_then(|value| value.to_str()) != Some("db") + { + return Err(format!( + "Bestie recovery journal contains an invalid retention path: {}", + assignment.path.display() + )); + } + } + Ok(Some(journal)) +} + +fn remove_recovery_journal(base_dir: &Path) -> Result<(), String> { + let path = recovery_journal_path(base_dir); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove Bestie recovery journal {}: {error}", + path.display() + )), + } +} + +fn retention_db_paths(base_dir: &Path) -> Result, String> { + let retention_dir = base_dir.join("retention"); + let entries = match fs::read_dir(&retention_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "failed to read retention directory {}: {error}", + retention_dir.display() + )) + } + }; + + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "failed to inspect retention directory {}: {error}", + retention_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("db") { + continue; + } + paths.push(path); + } + paths.sort(); + Ok(paths) +} + +fn matching_assignments( + base_dir: &Path, + agent_pubkey: &str, +) -> Result, String> { + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let mut assignments = Vec::new(); + // Read and validate every scope before mutating any of them. A broken later + // database therefore cannot leave an already-cleared prefix behind. + for path in retention_db_paths(base_dir)? { + let conn = open_retention_db(&path)?; + ensure_table(&conn)?; + if assignment_matches(&conn, &normalized)? { + assignments.push(ScopedAssignment { + agent_pubkey: normalized.clone(), + path, + }); + } + } + Ok(assignments) +} + +fn clear_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let conn = open_retention_db(&assignment.path)?; + conn.execute( + "DELETE FROM bestie_assignments WHERE singleton = 1 AND agent_pubkey = ?1", + params![assignment.agent_pubkey], + ) + .map_err(|error| { + format!( + "failed to clear bestie assignment in {}: {error}", + assignment.path.display() + ) + })?; + Ok(()) +} + +fn apply_to_assignments( + assignments: &[ScopedAssignment], + mut apply: impl FnMut(&ScopedAssignment) -> Result<(), String>, + action: &str, +) -> Result<(), String> { + let mut failures = Vec::new(); + for assignment in assignments { + if let Err(error) = apply(assignment) { + failures.push(format!("{}: {error}", assignment.path.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "failed to {action} Bestie assignments: {}", + failures.join("; ") + )) + } +} + +fn restore_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let mut conn = open_retention_db(&assignment.path)?; + replace_assignment(&mut conn, &assignment.agent_pubkey).map(|_| ()) +} + +fn restore_assignments(assignments: &[ScopedAssignment]) -> Result<(), String> { + apply_to_assignments(assignments, restore_scope, "restore") +} + +/// Replay a durable interrupted-deletion journal. +/// +/// The managed-agent store is authoritative for which side of the operation +/// committed: a retained agent gets its exact pre-delete assignments restored; +/// an absent agent gets those exact assignments cleared. The journal is only +/// removed after every scope reaches that deterministic state. +pub fn recover_pending_assignment_cleanup( + base_dir: &Path, + agent_exists: impl FnOnce(&str) -> bool, +) -> Result<(), String> { + let Some(journal) = load_recovery_journal(base_dir)? else { + return Ok(()); + }; + let agent_pubkey = journal + .assignments + .first() + .map(|assignment| assignment.agent_pubkey.as_str()) + .ok_or_else(|| "Bestie recovery journal contains no assignments".to_string())?; + if journal + .assignments + .iter() + .any(|assignment| assignment.agent_pubkey != agent_pubkey) + { + return Err("Bestie recovery journal contains multiple agents".to_string()); + } + if agent_exists(agent_pubkey) { + restore_assignments(&journal.assignments)?; + } else { + apply_to_assignments(&journal.assignments, clear_scope, "clear")?; + } + remove_recovery_journal(base_dir) +} + +fn clear_scoped_assignments( + assignments: &[ScopedAssignment], + mut clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result<(), String> { + for assignment in assignments { + clear(assignment)?; + } + Ok(()) +} + +fn rollback_with_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], + error: String, + restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + match apply_to_assignments(assignments, restore, "restore") { + Ok(()) => match remove_recovery_journal(base_dir) { + Ok(()) => Err(error), + Err(journal_error) => Err(format!("{error}; {journal_error}")), + }, + Err(restore_error) => Err(format!("{error}; {restore_error}")), + } +} + +fn with_agent_assignments_cleared_using( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, + clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, + mut restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + if load_recovery_journal(base_dir)?.is_some() { + return Err("pending Bestie assignment recovery must complete before deletion".to_string()); + } + let assignments = matching_assignments(base_dir, agent_pubkey)?; + if assignments.is_empty() { + return delete(); + } + persist_recovery_journal(base_dir, &assignments)?; + if let Err(error) = clear_scoped_assignments(&assignments, clear) { + return rollback_with_journal(base_dir, &assignments, error, &mut restore); + } + match delete() { + Ok(value) => { + if let Err(error) = remove_recovery_journal(base_dir) { + // The authoritative managed-agent write already committed. + // Keep the journal as a durable cleanup record; launch/command + // recovery will observe the absent agent, re-clear these exact + // scopes idempotently, and retry journal removal. + eprintln!("buzz-desktop: {error}; cleanup will retry"); + } + Ok(value) + } + Err(error) => rollback_with_journal(base_dir, &assignments, error, &mut restore), + } +} + +/// Run agent deletion work with this agent's community-scoped Bestie +/// assignments temporarily cleared. +/// +/// Call this while holding `managed_agents_store_lock`. Every matching scope is +/// snapshotted before the first write. A partial clear, or any later stop/save +/// failure returned by `delete`, restores the snapshot before the error is +/// propagated. Assignments remain cleared only when `delete` succeeds. +pub fn with_agent_assignments_cleared( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, +) -> Result { + with_agent_assignments_cleared_using(base_dir, agent_pubkey, delete, clear_scope, restore_scope) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connection() -> Connection { + Connection::open_in_memory().unwrap_or_else(|error| panic!("open test db: {error}")) + } + + #[test] + fn assignment_is_singleton_and_idempotent() { + let mut conn = connection(); + let first = replace_assignment(&mut conn, &"A".repeat(64)) + .unwrap_or_else(|error| panic!("assign first: {error}")); + assert_eq!(first.agent_pubkey, "a".repeat(64)); + + let same = replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("reassign same: {error}")); + assert_eq!(same.agent_pubkey, "a".repeat(64)); + + let replaced = replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert_eq!(replaced.agent_pubkey, "b".repeat(64)); + } + + #[test] + fn stale_resolver_is_fenced_after_replace_and_clear_is_idempotent() { + let mut conn = connection(); + replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("assign: {error}")); + replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert!(!assignment_matches(&conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("check stale assignment: {error}"))); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear: {error}")); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear again: {error}")); + assert_eq!( + get_assignment(&conn).unwrap_or_else(|error| panic!("read: {error}")), + None + ); + } + + #[test] + fn deleting_agent_clears_every_matching_scope_and_preserves_other_assignments() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let other = "b".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + let third_path = retention_dir.join("third.db"); + replace_assignment( + &mut open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("open first db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign first scope: {error}")); + replace_assignment( + &mut open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("open second db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign second scope: {error}")); + replace_assignment( + &mut open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("open third db: {error}")), + &other, + ) + .unwrap_or_else(|error| panic!("assign third scope: {error}")); + + with_agent_assignments_cleared(dir.path(), &agent, || Ok(())) + .unwrap_or_else(|error| panic!("clear agent assignments: {error}")); + assert_eq!( + get_assignment( + &open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("reopen first db: {error}")) + ) + .unwrap_or_else(|error| panic!("read first scope: {error}")), + None + ); + assert_eq!( + get_assignment( + &open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("reopen third db: {error}")) + ) + .unwrap_or_else(|error| panic!("read third scope: {error}")) + .map(|assignment| assignment.agent_pubkey), + Some(other) + ); + } + + #[test] + fn later_scope_clear_failure_restores_the_already_cleared_prefix() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + for name in ["first.db", "second.db"] { + replace_assignment( + &mut open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("open {name}: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {name}: {error}")); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Ok(()), + |assignment| { + if assignment.path.ends_with("second.db") { + Err("injected later retention DB failure".to_string()) + } else { + clear_scope(assignment) + } + }, + restore_scope, + ); + + assert!(result.is_err()); + for name in ["first.db", "second.db"] { + let conn = open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("reopen {name}: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read {name}: {error}"))); + } + } + + fn assert_later_deletion_failure_restores_assignment(failure: &str) { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let path = retention_dir.join("owner.db"); + let agent = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&path) + .unwrap_or_else(|error| panic!("open assignment db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign agent: {error}")); + + let result = with_agent_assignments_cleared(dir.path(), &agent, || { + Err::<(), _>(failure.to_string()) + }); + + assert_eq!(result, Err(failure.to_string())); + let conn = open_retention_db(&path) + .unwrap_or_else(|error| panic!("reopen assignment db: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); + } + + #[test] + fn stop_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected stop failure"); + } + + #[test] + fn save_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected save failure"); + } + + #[test] + fn failed_rollback_leaves_a_durable_journal_that_repairs_on_restart() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + for path in [&first_path, &second_path] { + replace_assignment( + &mut open_retention_db(path) + .unwrap_or_else(|error| panic!("open {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {}: {error}", path.display())); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Err::<(), _>("injected managed-agent save failure".to_string()), + clear_scope, + |assignment| { + if assignment.path == second_path { + Err("injected restore failure".to_string()) + } else { + restore_scope(assignment) + } + }, + ); + + assert!(result + .as_ref() + .is_err_and(|error| error.contains("injected restore failure"))); + assert!(recovery_journal_path(dir.path()).exists()); + assert!(!assignment_matches( + &open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("reopen second scope: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("read torn scope: {error}"))); + + recover_pending_assignment_cleanup(dir.path(), |pubkey| pubkey == agent) + .unwrap_or_else(|error| panic!("replay durable recovery: {error}")); + + for path in [&first_path, &second_path] { + assert!(assignment_matches( + &open_retention_db(path) + .unwrap_or_else(|error| panic!("reopen {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("read repaired {}: {error}", path.display()))); + } + assert!(!recovery_journal_path(dir.path()).exists()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 392059dfb6c..a66f9c75ba2 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,6 +11,7 @@ pub(crate) use agent_env::{ mod agent_description; pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; +pub(crate) mod bestie_assignment; pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index bbda1e7c860..1d7fb34120e 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,6 @@ use super::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, + bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut, + kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; @@ -114,6 +115,11 @@ pub async fn restore_managed_agents_on_launch( } let mut records = load_managed_agents(app)?; + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d8..57cd1554b4e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { ProtectedGlobalOverlay } from "@protected-feature-components"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; @@ -986,6 +987,7 @@ export function AppShell() { onOpenChange={setIsSendFeedbackOpen} open={isSendFeedbackOpen} /> + {!isHuddleRoom ? : null} diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index bd67def785f..60d16c833eb 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -9,6 +9,7 @@ type AgentIdentityCardProps = { ariaLabel: string; avatar?: ReactNode; avatarUrl?: string | null; + footerAccessory?: ReactNode; dataTestId: string; label: string; /** @@ -28,6 +29,7 @@ export function AgentIdentityCard({ avatar, avatarUrl, dataTestId, + footerAccessory, label, subtitle, onClick, @@ -74,20 +76,25 @@ export function AgentIdentityCard({
{actions}
) : null} -
- - {label} - - {subtitle ? ( - - {subtitle} +
+
+ + {label} - ) : null} - {/* pointer-events-auto: the overlay button above has pointer-events-none - on this container, but the status badge itself (a sibling of the button - in z-order) needs hover so the restart diff tooltip can fire. */} - {statusBadge ? ( -
{statusBadge}
+ {subtitle ? ( + + {subtitle} + + ) : null} + {/* pointer-events-auto: the overlay button above has pointer-events-none + on this container, but the status badge itself (a sibling of the button + in z-order) needs hover so the restart diff tooltip can fire. */} + {statusBadge ? ( +
{statusBadge}
+ ) : null} +
+ {footerAccessory ? ( +
{footerAccessory}
) : null}
diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 36f950d4c59..8b914e76343 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -16,6 +16,10 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { Badge } from "@/shared/ui/badge"; +import { + ProtectedBestieCardBadge, + useProtectedBestiePubkey, +} from "@protected-feature-components"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { AgentIdentityCard } from "./AgentIdentityCard"; import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; @@ -97,6 +101,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { } = props; const isArchived = useIsArchivedPredicate(); + const bestiePubkey = useProtectedBestiePubkey(agents)?.toLowerCase() ?? null; const { groups, ungrouped, unknown } = React.useMemo( () => buildUnifiedGroups(personas, agents, isArchived), [personas, agents, isArchived], @@ -154,6 +159,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { )} agent={profileAgent} defaultModel={defaultModel} + isBestie={profileAgent?.pubkey.toLowerCase() === bestiePubkey} key={group.persona.id} persona={group.persona} restartingAgentPubkey={restartingAgentPubkey} @@ -175,6 +181,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} defaultModel={defaultModel} groupKey="__unknown__" + bestiePubkey={bestiePubkey} label="Unknown agents" restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} @@ -190,6 +197,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} defaultModel={defaultModel} groupKey="__ungrouped__" + bestiePubkey={bestiePubkey} label="Custom agents" restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} @@ -224,6 +232,7 @@ function AgentPersonaCard({ actions, agent, defaultModel, + isBestie, persona, restartingAgentPubkey, startingAgentPubkey, @@ -240,6 +249,7 @@ function AgentPersonaCard({ ) => React.ReactNode; agent: ManagedAgent | undefined; defaultModel: string; + isBestie: boolean; persona: AgentPersona; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; @@ -316,6 +326,11 @@ function AgentPersonaCard({ } avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} + footerAccessory={ + agent ? ( + + ) : null + } label={title} subtitle={subtitle} onClick={() => { @@ -344,6 +359,7 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, + isBestie, defaultModel, restartingAgentPubkey, startingAgentPubkey, @@ -352,6 +368,7 @@ function StandaloneAgentCard({ onStartAgent, }: { agent: ManagedAgent; + isBestie: boolean; defaultModel: string; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; @@ -398,6 +415,9 @@ function StandaloneAgentCard({ } avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} + footerAccessory={ + + } label={title} subtitle={ // Definition-less instance: no authored description exists, so fall @@ -450,6 +470,7 @@ function CollapsibleAgentGroup({ groupKey, label, agents, + bestiePubkey, collapsed, defaultModel, restartingAgentPubkey, @@ -462,6 +483,7 @@ function CollapsibleAgentGroup({ groupKey: string; label: string; agents: ManagedAgent[]; + bestiePubkey: string | null; collapsed: ReadonlySet; defaultModel: string; restartingAgentPubkey: string | null; @@ -496,6 +518,7 @@ function CollapsibleAgentGroup({ ) : null} + + {hasReactionAction && quickReactionItems.length > 0 ? ( ); diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9c7f3cf1661..647ed632644 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -73,6 +73,7 @@ import { SidebarRail, useSidebar, } from "@/shared/ui/sidebar"; +import { useProtectedVisibleDirectMessages } from "@protected-feature-components"; export function AppSidebar({ addCommunityPrefill, @@ -367,10 +368,14 @@ export function AppSidebar({ ), [channels, sortModeFor], ); - const directMessages = React.useMemo( + const allDirectMessages = React.useMemo( () => channels.filter((channel) => channel.channelType === "dm"), [channels], ); + const directMessages = useProtectedVisibleDirectMessages( + allDirectMessages, + currentPubkey, + ); const isSelectedDirectMessage = selectedView === "channel" && directMessages.some((channel) => channel.id === selectedChannelId); diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index a9bf7058eb6..660f7d9a8aa 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -12,6 +12,7 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; +import { ProtectedBestieSidebarEntry } from "@protected-feature-components"; type SidebarSelectedView = | "home" @@ -167,6 +168,7 @@ export function AppSidebarPrimaryMenu({ Agents + + + + ); +} diff --git a/desktop/src/protectedFeatures/bestie/BestieGlobalOverlay.tsx b/desktop/src/protectedFeatures/bestie/BestieGlobalOverlay.tsx new file mode 100644 index 00000000000..ccecdc280c6 --- /dev/null +++ b/desktop/src/protectedFeatures/bestie/BestieGlobalOverlay.tsx @@ -0,0 +1,212 @@ +import * as React from "react"; +import { LayoutGroup, motion } from "motion/react"; +import { createPortal } from "react-dom"; + +import { Bloom } from "./BloomMenu"; +import { BestiePopover, BestieTriggerVisual } from "./BestiePopover"; +import { useBestie } from "./useBestie"; + +type Point = { x: number; y: number }; +type Placement = { + anchor: "start" | "end"; + direction: "top" | "bottom"; +}; +type DragBounds = { + maxX: number; + maxY: number; + minX: number; + minY: number; +}; +const AVATAR_SIZE = 48; +const EDGE_INSET = 16; +const BESTIE_AVATAR_LAYOUT_ID = "bestie-floating-bloom-avatar"; + +function clampPoint(point: Point): Point { + return { + x: Math.min( + Math.max(EDGE_INSET, point.x), + Math.max(EDGE_INSET, window.innerWidth - AVATAR_SIZE - EDGE_INSET), + ), + y: Math.min( + Math.max(EDGE_INSET, point.y), + Math.max(EDGE_INSET, window.innerHeight - AVATAR_SIZE - EDGE_INSET), + ), + }; +} + +function initialPoint(): Point { + return clampPoint({ x: window.innerWidth - AVATAR_SIZE - 20, y: 52 }); +} + +export function BestieGlobalOverlay() { + const bestie = useBestie(); + const [open, setOpen] = React.useState(false); + const [placement, setPlacement] = React.useState(null); + const [shareAvatarLayout, setShareAvatarLayout] = React.useState(true); + const [dragging, setDragging] = React.useState(false); + const [position, setPosition] = React.useState(initialPoint); + const dragRef = React.useRef< + | { + bounds: DragBounds; + moved: boolean; + open: boolean; + origin: Point; + pointerId: number; + start: Point; + } + | undefined + >(undefined); + const suppressClickRef = React.useRef(false); + + React.useEffect(() => { + const handleResize = () => setPosition((current) => clampPoint(current)); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const currentPlacement: Placement = { + direction: + position.y > (window.innerHeight - AVATAR_SIZE) / 2 ? "top" : "bottom", + anchor: + position.x > (window.innerWidth - AVATAR_SIZE) / 2 ? "end" : "start", + }; + const activePlacement = open && placement ? placement : currentPlacement; + + const updateOpen = (nextOpen: boolean) => { + setShareAvatarLayout(nextOpen); + if (nextOpen) setPlacement(currentPlacement); + setOpen(nextOpen); + }; + + return createPortal( +
{ + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = undefined; + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + onPointerDown={(event) => { + if (event.button !== 0) return; + const target = event.target as HTMLElement; + if ( + open && + (!target.closest("[data-bestie-drag-handle]") || + target.closest("button, input, textarea, a")) + ) { + return; + } + const surface = open + ? event.currentTarget + .querySelector('[data-testid="bestie-bloom-content"]') + ?.getBoundingClientRect() + : event.currentTarget.getBoundingClientRect(); + if (!surface) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + setShareAvatarLayout(false); + dragRef.current = { + bounds: { + maxX: window.innerWidth - EDGE_INSET - surface.right, + maxY: window.innerHeight - EDGE_INSET - surface.bottom, + minX: EDGE_INSET - surface.left, + minY: EDGE_INSET - surface.top, + }, + open, + pointerId: event.pointerId, + origin: position, + start: { x: event.clientX, y: event.clientY }, + moved: false, + }; + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const rawX = event.clientX - drag.start.x; + const rawY = event.clientY - drag.start.y; + const dx = Math.min(drag.bounds.maxX, Math.max(drag.bounds.minX, rawX)); + const dy = Math.min(drag.bounds.maxY, Math.max(drag.bounds.minY, rawY)); + if (Math.hypot(dx, dy) > 4) drag.moved = true; + setPosition( + clampPoint({ x: drag.origin.x + dx, y: drag.origin.y + dy }), + ); + }} + onPointerUp={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (!drag.open) suppressClickRef.current = drag.moved; + dragRef.current = undefined; + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (!drag.open && !drag.moved) updateOpen(true); + }} + style={{ + left: 0, + top: 0, + transform: `translate3d(${position.x}px, ${position.y}px, 0)`, + willChange: "transform", + }} + > + + { + if (nextOpen && suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + updateOpen(nextOpen); + }} + open={open} + > + { + if (isOpen) setShareAvatarLayout(false); + }} + > + + + + + + + updateOpen(false)} + /> + + + + +
, + document.body, + ); +} diff --git a/desktop/src/protectedFeatures/bestie/BestieMessageAction.tsx b/desktop/src/protectedFeatures/bestie/BestieMessageAction.tsx new file mode 100644 index 00000000000..f69e093ad1e --- /dev/null +++ b/desktop/src/protectedFeatures/bestie/BestieMessageAction.tsx @@ -0,0 +1,46 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { BestiePopover, BestieTriggerVisual } from "./BestiePopover"; +import { useBestie } from "./useBestie"; + +export function BestieMessageAction({ + channelId, + message, +}: { + channelId?: string | null; + message: TimelineMessage; +}) { + const bestie = useBestie(); + const [open, setOpen] = React.useState(false); + return ( + + + + + + + + Ask Bestie + + + setOpen(false)} + /> + + + ); +} +import * as React from "react"; diff --git a/desktop/src/protectedFeatures/bestie/BestiePopover.tsx b/desktop/src/protectedFeatures/bestie/BestiePopover.tsx new file mode 100644 index 00000000000..7a542501acc --- /dev/null +++ b/desktop/src/protectedFeatures/bestie/BestiePopover.tsx @@ -0,0 +1,496 @@ +import { ArrowUp, Plus, X } from "lucide-react"; +import { motion } from "motion/react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, + useToggleReactionMutation, +} from "@/features/messages/hooks"; +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; +import type { TimelineMessage } from "@/features/messages/types"; +import { TimelineMessageList } from "@/features/messages/ui/TimelineMessageList"; +import { ProtectedMessageActionsBoundary } from "@protected-feature-components"; +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { useProfileQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel, ManagedAgent, PresenceStatus } from "@/shared/api/types"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +} from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { Textarea } from "@/shared/ui/textarea"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { buildBestieMessageContext } from "./bestieMessageContext"; +import { useBestie } from "./useBestie"; + +export function BestieTriggerVisual({ + agent, + className, + compact = false, + imageDraggable, +}: { + agent: ManagedAgent | null; + className?: string; + compact?: boolean; + imageDraggable?: boolean; +}) { + if (agent) { + return ( + + ); + } + + return ( +