diff --git a/.env.example b/.env.example
index fb1a3c2aa0..9e35203524 100644
--- a/.env.example
+++ b/.env.example
@@ -159,6 +159,11 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
# Path to a file containing the heartbeat prompt.
# BUZZ_ACP_HEARTBEAT_PROMPT_FILE=
+# ── Desktop development ──────────────────────────────────────────────────────
+# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
+# app launch while keeping the current identity and relay data.
+# VITE_BUZZ_FORCE_FRESH_ONBOARDING=true
+
# ── Subscription & filtering ─────────────────────────────────────────────────
# Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
# BUZZ_ACP_SUBSCRIBE=mentions
diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml
new file mode 100644
index 0000000000..a321714bcb
--- /dev/null
+++ b/.intersect/sadscan.yaml
@@ -0,0 +1,4 @@
+# Cargo registry checksums are integrity hashes generated by Cargo, not payment data.
+exclude_rules_for_files:
+ sq.pii.cc.visa:
+ - Cargo.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b924d7135..ae835ea9dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## v0.4.13
+
+- fix(desktop): compare relay media origins case-insensitively so uploads render when the saved community URL has uppercase characters
+- fix(desktop): invalidate stale media lookups so an in-flight relay-origin fetch cannot repopulate caches after a community reset
+
+## v0.4.12
+
+- fix(desktop): proxy authenticated relay media after cold-start community initialization
+- fix(desktop): keep shared-compute consumers admitted (WIP: multi-workspace publishing) ([#2000](https://github.com/block/buzz/pull/2000)) ([`5d77fa57`](https://github.com/block/buzz/commit/5d77fa57))
+- feat(desktop): reskin provider, config, community, profile, and team onboarding pages ([#2003](https://github.com/block/buzz/pull/2003)) ([`f054df73`](https://github.com/block/buzz/commit/f054df73))
+
+
+## v0.4.11
+
+- Fix policy receipts for cold-launch invite links ([#2005](https://github.com/block/buzz/pull/2005)) ([`1d8d006a`](https://github.com/block/buzz/commit/1d8d006aaf078ffd5b92eae06334b7bb908b680e))
+- Preserve persistent agent audience order ([#1989](https://github.com/block/buzz/pull/1989)) ([`fb8e6827`](https://github.com/block/buzz/commit/fb8e6827c758c73c9c6e16965dcdcecd00a5827c))
+- feat(desktop): create channels in sections ([#1996](https://github.com/block/buzz/pull/1996)) ([`9b649740`](https://github.com/block/buzz/commit/9b6497402f343e380f6c79b97a01c2d52bd12f17))
+- feat(desktop): graduate community rail ([#1995](https://github.com/block/buzz/pull/1995)) ([`f06b59ff`](https://github.com/block/buzz/commit/f06b59ff13bef0c031c8e4c3bdf979e120ea76d5))
+
+
## v0.4.10
- feat(desktop): onboarding step dots, identity/key page restyle, overflow scroll ([#1993](https://github.com/block/buzz/pull/1993)) ([`f2522995c`](https://github.com/block/buzz/commit/f2522995cbea67c96b79d5de6c19c665e465027d))
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 2b6e8d45d1..3d86125298 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1286,12 +1286,6 @@ async fn tokio_main() -> Result<()> {
let presence_publisher = relay.event_publisher();
let presence_keys = config.keys.clone();
- if config.presence_enabled {
- match publish_presence(&presence_publisher, &presence_keys, "online").await {
- Ok(_) => tracing::info!("presence set to online"),
- Err(e) => tracing::warn!("failed to set initial presence: {e}"),
- }
- }
// Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag.
let startup_owner: Option = resolve_agent_owner(&config);
@@ -1413,6 +1407,16 @@ async fn tokio_main() -> Result<()> {
}
}
+ // Online means the harness can receive work, not merely that its socket is
+ // connected. Publishing after channel subscriptions gives desktop callers
+ // a durable readiness boundary before they send a startup mention.
+ if config.presence_enabled {
+ match publish_presence(&presence_publisher, &presence_keys, "online").await {
+ Ok(_) => tracing::info!("presence set to online"),
+ Err(e) => tracing::warn!("failed to set initial presence: {e}"),
+ }
+ }
+
let dedup_mode = config.dedup_mode;
let mut queue =
EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs);
diff --git a/desktop/package.json b/desktop/package.json
index 0d5dca3fbe..f07b282d4f 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
- "version": "0.4.10",
+ "version": "0.4.13",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index fa6f9ae19f..b6296f0f3a 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -20,6 +20,7 @@ export default defineConfig({
name: "smoke",
testMatch: [
"**/smoke.spec.ts",
+ "**/onboarding-docked-cta-screenshots.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 15674a14ec..bf7177e6c2 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -202,11 +202,6 @@ const overrides = new Map([
// mapper. This is the existing API boundary; split remains queued.
// team-instructions-first-class: createManagedAgent Tauri bridge threads the
// new teamId input through to the backend (+1 line).
- // #1970 merge-skew: add-community deep-link wiring (+4) was based before
- // #1923 (+3) landed, so PR CI was green while the merged total crossed the
- // cap (996 -> 1003). Pre-existing red on main, not one PR's feature debt.
- // Queued to split with the rest of this list.
- ["src/app/AppShell.tsx", 1003],
["src/shared/api/tauri.ts", 1305],
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
@@ -376,7 +371,10 @@ const overrides = new Map([
// here beside `build_app_state` and its sibling client; its doc comment
// carries the load-bearing SSRF rationale. Extraction would only relocate,
// not reduce, the security-critical code.
- ["src-tauri/src/app_state.rs", 1068],
+ // +5 (1068 -> 1073): merge with main, which independently added the
+ // managed_agent_profile_reconcile_enabled flag (field + doc + init) under
+ // its own 1042-line override. Union of two separately approved additions.
+ ["src-tauri/src/app_state.rs", 1073],
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
// class grew from ~700 lines to ~1019 with the addition of
// splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index c59ba28343..2db15a9b54 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -972,7 +972,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
-version = "0.4.10"
+version = "0.4.13"
dependencies = [
"anyhow",
"arboard",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index cf8bb280ec..68005f41d3 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -2,7 +2,7 @@
[package]
name = "buzz-desktop"
-version = "0.4.10"
+version = "0.4.13"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index 6d509318ec..3d910c3133 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -34,6 +34,10 @@ pub struct AppState {
/// restore. `apply_workspace` consumes it after installing the workspace
/// relay and identity, so agents never start against the fallback relay.
pub managed_agent_restore_pending: AtomicBool,
+ /// Whether desktop may repair managed-agent kind:0 profiles from its local
+ /// records. Disabled by the agent-managed profiles experiment so an agent's
+ /// own profile updates are not overwritten on start or restore.
+ pub managed_agent_profile_reconcile_enabled: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes the restore spawn/register transition with shutdown cleanup,
@@ -196,6 +200,7 @@ pub fn build_app_state() -> AppState {
),
relay_url_override: Mutex::new(None),
managed_agent_restore_pending: AtomicBool::new(false),
+ managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
shutdown_started: AtomicBool::new(false),
managed_agent_restore_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs
index 54b52bd197..b654158401 100644
--- a/desktop/src-tauri/src/commands/agent_settings.rs
+++ b/desktop/src-tauri/src/commands/agent_settings.rs
@@ -1,4 +1,5 @@
-use tauri::{AppHandle, Manager};
+use std::sync::atomic::Ordering;
+use tauri::{AppHandle, Manager, State};
use crate::{
app_state::AppState,
@@ -10,6 +11,13 @@ use crate::{
util::now_iso,
};
+#[tauri::command]
+pub fn set_agent_managed_profiles(enabled: bool, state: State<'_, AppState>) {
+ state
+ .managed_agent_profile_reconcile_enabled
+ .store(!enabled, Ordering::Release);
+}
+
#[tauri::command]
pub async fn set_managed_agent_start_on_app_launch(
pubkey: String,
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 1718d54de2..6ebe6b7c4a 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -991,7 +991,11 @@ pub async fn start_managed_agent(
// profile is published on the relay. This self-heals cases where the initial
// profile sync at creation time failed silently. For legacy records (pre-PR-921)
// with no persisted avatar, this also backfills the avatar from the relay.
- if result.is_ok() {
+ if result.is_ok()
+ && state
+ .managed_agent_profile_reconcile_enabled
+ .load(std::sync::atomic::Ordering::Acquire)
+ {
let reconcile_pubkey = pubkey.clone();
let reconcile_app = app.clone();
tauri::async_runtime::spawn(async move {
diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs
index 236fd9f4cc..0675d4c48f 100644
--- a/desktop/src-tauri/src/commands/agents_profile.rs
+++ b/desktop/src-tauri/src/commands/agents_profile.rs
@@ -81,6 +81,13 @@ pub(crate) async fn reconcile_agent_profile(
&relay_ws_url_with_override(state),
);
+ if !state
+ .managed_agent_profile_reconcile_enabled
+ .load(std::sync::atomic::Ordering::Acquire)
+ {
+ return Ok(());
+ }
+
// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
@@ -136,6 +143,13 @@ pub(crate) async fn reconcile_agent_profile(
let agent_keys = Keys::parse(&data.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
+ if !state
+ .managed_agent_profile_reconcile_enabled
+ .load(std::sync::atomic::Ordering::Acquire)
+ {
+ return Ok(());
+ }
+
sync_managed_agent_profile(
state,
&relay_url,
diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs
index c40e5d4e9a..191fafe60d 100644
--- a/desktop/src-tauri/src/commands/canvas.rs
+++ b/desktop/src-tauri/src/commands/canvas.rs
@@ -23,14 +23,22 @@ pub async fn get_canvas(
.await?;
let Some(event) = events.first() else {
- return Ok(serde_json::json!({ "content": "" }));
+ // Explicit nulls: the TS caller distinguishes "no canvas yet" from
+ // "canvas exists" via `updated_at`/`author`, so these keys must be
+ // present (absent keys deserialize as `undefined`, not `null`).
+ return Ok(serde_json::json!({
+ "content": "",
+ "event_id": null,
+ "updated_at": null,
+ "author": null,
+ }));
};
Ok(serde_json::json!({
"content": event.content,
"event_id": event.id.to_hex(),
- "created_at": event.created_at.as_secs(),
- "pubkey": event.pubkey.to_hex(),
+ "updated_at": event.created_at.as_secs(),
+ "author": event.pubkey.to_hex(),
}))
}
diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs
index 36cb458db1..ad55d95060 100644
--- a/desktop/src-tauri/src/commands/mesh_llm.rs
+++ b/desktop/src-tauri/src/commands/mesh_llm.rs
@@ -47,17 +47,77 @@ const RELAY_MESH_RUNTIME_NO_TARGET: &str =
pub type CmdResult = Result;
+fn advance_mesh_status_cursor(
+ filter: &mut serde_json::Value,
+ page: &[nostr::Event],
+) -> Result<(u64, String), String> {
+ let last = page
+ .last()
+ .ok_or_else(|| "cannot advance an empty mesh status page".to_string())?;
+ let cursor = (last.created_at.as_secs(), last.id.to_hex());
+ filter["until"] = serde_json::json!(cursor.0);
+ filter["before_id"] = serde_json::json!(cursor.1);
+ Ok(cursor)
+}
+
+async fn query_mesh_discovery_events(state: &AppState) -> Result, String> {
+ let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?;
+ let member_pubkeys = mesh_llm::current_member_pubkeys(&events);
+ if member_pubkeys.is_empty() {
+ // Distinguish "relay returned a membership snapshot listing zero
+ // members" (authoritative empty — allowed to shrink the roster to
+ // self-only) from "no membership snapshot came back at all" (a
+ // transient gap / replication lag). The relay publishes an explicit
+ // kind:13534 event even for a zero-member community, so its absence
+ // means the query is incomplete: surface it as an error so the
+ // reconcile loop keeps the current allowlist instead of flapping the
+ // node down to self-only on a successful-but-empty response.
+ if !mesh_llm::has_membership_snapshot(&events) {
+ return Err("relay returned no membership snapshot".to_string());
+ }
+ return Ok(events);
+ }
+ let mut status_filter = mesh_llm::mesh_status_filter();
+ status_filter["authors"] = serde_json::json!(member_pubkeys);
+ let mut previous_cursor: Option<(u64, String)> = None;
+
+ loop {
+ let page = relay::query_relay(state, &[status_filter.clone()]).await?;
+ let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE;
+ if !done {
+ let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?;
+ if previous_cursor.as_ref() == Some(&cursor) {
+ return Err("mesh status pagination did not advance".to_string());
+ }
+ previous_cursor = Some(cursor);
+ }
+ events.extend(page);
+ if done {
+ return Ok(events);
+ }
+ }
+}
+
/// Resolve the admission roster by intersecting member-signed mesh status
-/// reporters with the current NIP-43 direct-member list. Missing membership or
-/// a failed query returns an empty roster, which the runtime normalizes to
-/// self-only admission.
-pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Vec {
- let filters = [
- mesh_llm::mesh_status_filter(),
- mesh_llm::relay_membership_filter(),
- ];
- match relay::query_relay(state, &filters).await {
- Ok(events) => mesh_llm::owner_ids_from_events(&events),
+/// reporters with the current NIP-43 direct-member list.
+///
+/// Returns `Err` when the relay query fails. Callers MUST distinguish this from
+/// an `Ok(empty)` roster (a genuinely empty community): a failed query must
+/// never be collapsed into "self-only", or a transient relay blip de-admits
+/// every other member. `reconcile_roster` relies on this to keep the current
+/// allowlist on error instead of restarting the node down to self-only.
+pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result, String> {
+ let events = query_mesh_discovery_events(state).await?;
+ Ok(mesh_llm::owner_ids_from_events(&events))
+}
+
+/// Resolve the roster for an initial node *start*, failing closed to self-only
+/// (an empty roster) when the relay query fails. This is safe only at start:
+/// there is no established allowlist to preserve yet. The periodic
+/// `reconcile_roster` path must NOT use this — it has a live roster to keep.
+pub(crate) async fn resolve_trusted_owner_ids_or_self_only(state: &AppState) -> Vec {
+ match resolve_trusted_owner_ids(state).await {
+ Ok(owners) => owners,
Err(error) => {
eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}");
Vec::new()
@@ -81,7 +141,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C
model_id: Some(config.model_id),
max_vram_gb: config.max_vram_gb,
join_token: None,
- trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await),
+ trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
};
let started = mesh_llm::DesktopMeshRuntime::start(request)
.await
@@ -101,7 +161,7 @@ pub async fn mesh_start_node(
// Frontend requests never carry a roster; resolve it here so every
// UI-started node enforces the member allowlist.
if request.trusted_owner_ids.is_none() {
- request.trusted_owner_ids = Some(resolve_trusted_owner_ids(&state).await);
+ request.trusted_owner_ids = Some(resolve_trusted_owner_ids_or_self_only(&state).await);
}
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
@@ -232,7 +292,7 @@ pub(crate) async fn ensure_client_node_for_model(
model_id: None,
max_vram_gb: None,
join_token: Some(join_token),
- trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await),
+ trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
};
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
@@ -272,14 +332,7 @@ pub(crate) async fn resolve_mesh_bootstrap_target(
if model_id.is_empty() {
return Ok(None);
}
- let events = relay::query_relay(
- state,
- &[
- mesh_llm::mesh_status_filter(),
- mesh_llm::relay_membership_filter(),
- ],
- )
- .await?;
+ let events = query_mesh_discovery_events(state).await?;
Ok(pick_serve_target_for_model(
mesh_llm::availability_from_events(events).serve_targets,
model_id,
@@ -426,6 +479,26 @@ mod tests {
}
}
+ #[test]
+ fn mesh_status_cursor_uses_relay_composite_tiebreak() {
+ let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status")
+ .custom_created_at(nostr::Timestamp::from(1_234))
+ .sign_with_keys(&nostr::Keys::generate())
+ .expect("sign test status");
+ let mut filter = mesh_llm::mesh_status_filter();
+
+ let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event))
+ .expect("advance status cursor");
+
+ assert_eq!(cursor, (1_234, event.id.to_hex()));
+ assert_eq!(filter["until"], serde_json::json!(1_234));
+ assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
+ assert_eq!(
+ filter["limit"],
+ serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE)
+ );
+ }
+
#[test]
fn pick_serve_target_returns_first_match_for_model() {
let targets = vec![
diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs
index 283ea7ace6..1928d69d23 100644
--- a/desktop/src-tauri/src/commands/messages.rs
+++ b/desktop/src-tauri/src/commands/messages.rs
@@ -622,14 +622,42 @@ async fn find_managed_agent_channel_message_by_marker(
fn marker_author_for_scope<'a>(
marker_scope: Option<&str>,
- agent_pubkey: &'a str,
-) -> Option<&'a str> {
+ agent_pubkey: Option<&'a str>,
+) -> Result, String> {
match marker_scope {
- Some("channel") => None,
- _ => Some(agent_pubkey),
+ Some("channel") => Ok(None),
+ Some("agent") | None => agent_pubkey
+ .map(Some)
+ .ok_or_else(|| "agent pubkey is required for agent-scoped markers".to_string()),
+ Some(scope) => Err(format!("unsupported marker scope: {scope}")),
}
}
+#[tauri::command]
+pub async fn has_managed_agent_channel_message_marker(
+ channel_id: String,
+ marker: String,
+ agent_pubkey: Option,
+ marker_scope: Option,
+ state: State<'_, AppState>,
+) -> Result {
+ uuid::Uuid::parse_str(&channel_id)
+ .map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
+ let marker = marker.trim();
+ if marker.is_empty() {
+ return Err("message marker is required".into());
+ }
+ let agent_pubkey = agent_pubkey
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+
+ let marker_author = marker_author_for_scope(marker_scope.as_deref(), agent_pubkey)?;
+ find_managed_agent_channel_message_by_marker(&state, marker_author, &channel_id, marker)
+ .await
+ .map(|event| event.is_some())
+}
+
fn stored_managed_agent_auth_tag(auth_tag: Option<&str>) -> Option {
auth_tag
.map(str::trim)
@@ -663,6 +691,27 @@ fn managed_agent_submission_auth_tag(
legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey)
}
+fn build_managed_agent_channel_message(
+ channel_id: uuid::Uuid,
+ content: &str,
+ thread_ref: Option<&events::ThreadRef>,
+ mention_pubkeys: &[String],
+ client_tags: &[Vec],
+) -> Result {
+ let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();
+ events::build_message_with_client_tags(
+ channel_id,
+ content,
+ thread_ref,
+ &mention_refs,
+ &[],
+ &[],
+ &[],
+ client_tags,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn send_managed_agent_channel_message(
agent_pubkey: String,
@@ -670,6 +719,9 @@ pub async fn send_managed_agent_channel_message(
content: String,
marker: Option,
marker_scope: Option,
+ mention_pubkeys: Option>,
+ parent_event_id: Option,
+ additional_markers: Option>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result {
@@ -706,11 +758,15 @@ pub async fn send_managed_agent_channel_message(
}
let submission_auth_tag =
managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
+ let thread_ref = match parent_event_id.as_deref() {
+ Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
+ None => None,
+ };
if let Some(marker) = marker.as_deref() {
if let Some(existing) = find_managed_agent_channel_message_by_marker(
&state,
- marker_author_for_scope(marker_scope.as_deref(), &record.pubkey),
+ marker_author_for_scope(marker_scope.as_deref(), Some(&record.pubkey))?,
&channel_id,
marker,
)
@@ -718,26 +774,34 @@ pub async fn send_managed_agent_channel_message(
{
return Ok(SendChannelMessageResponse {
event_id: existing.id.to_hex(),
- parent_event_id: None,
- root_event_id: None,
- depth: 0,
+ parent_event_id: thread_ref
+ .as_ref()
+ .map(|reference| reference.parent_event_id.to_hex()),
+ root_event_id: thread_ref
+ .as_ref()
+ .map(|reference| reference.root_event_id.to_hex()),
+ depth: if thread_ref.is_some() { 1 } else { 0 },
created_at: existing.created_at.as_secs() as i64,
});
}
}
- let client_tags = marker
+ let mut client_tags = marker
.as_deref()
.map(|marker| vec![vec!["client".to_string(), marker.to_string()]])
.unwrap_or_default();
- let builder = events::build_message_with_client_tags(
+ for marker in additional_markers.unwrap_or_default() {
+ let marker = marker.trim();
+ if !marker.is_empty() {
+ client_tags.push(vec!["client".to_string(), marker.to_string()]);
+ }
+ }
+ let mentions = mention_pubkeys.unwrap_or_default();
+ let builder = build_managed_agent_channel_message(
channel_uuid,
trimmed,
- None,
- &[],
- &[],
- &[],
- &[],
+ thread_ref.as_ref(),
+ &mentions,
&client_tags,
)?;
let result =
@@ -745,157 +809,13 @@ pub async fn send_managed_agent_channel_message(
Ok(SendChannelMessageResponse {
event_id: result.event_id,
- parent_event_id: None,
- root_event_id: None,
- depth: 0,
+ parent_event_id: parent_event_id.clone(),
+ root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()),
+ depth: if parent_event_id.is_some() { 1 } else { 0 },
created_at: chrono::Utc::now().timestamp(),
})
}
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn marker_author_scope_defaults_to_agent() {
- assert_eq!(
- marker_author_for_scope(None, "agent-pubkey"),
- Some("agent-pubkey")
- );
- assert_eq!(
- marker_author_for_scope(Some("agent"), "agent-pubkey"),
- Some("agent-pubkey")
- );
- assert_eq!(
- marker_author_for_scope(Some("unknown"), "agent-pubkey"),
- Some("agent-pubkey")
- );
- }
-
- #[test]
- fn marker_author_scope_can_dedupe_across_channel() {
- assert_eq!(
- marker_author_for_scope(Some("channel"), "agent-pubkey"),
- None
- );
- }
-
- #[test]
- fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
- let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
-
- assert_eq!(filter["search"], serde_json::json!("pro"));
- assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
- assert_eq!(filter["limit"], serde_json::json!(12));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- }
-
- #[test]
- fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
- // The relay bridge's `extract_before_id` reads the composite tiebreak
- // from `before_id`. If this filter sent the id under any other key (an
- // earlier cut used `n`), the relay would silently drop the tiebreak and
- // the dense-second keyset would degrade to a bare inclusive `until` —
- // re-returning the same page forever. Pin the field name here so the
- // client/relay contract can't drift without a red test (the Playwright
- // mock reimplements the keyset in JS and cannot catch this).
- let filter =
- build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
-
- assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
- assert_eq!(filter["before_id"], serde_json::json!("ab"));
- assert_eq!(filter["limit"], serde_json::json!(200));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- assert!(
- !filter.contains_key("n"),
- "tiebreak must be `before_id`, not the `n` alias the relay ignores"
- );
- }
-
- #[test]
- fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
- // The relay bridge p-gates EVERY filter before routing
- // (`p_gated_filters_authorized`): a kindless filter "could match" a
- // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
- // before the thread-subtree query runs. The headline Lane-1 fix
- // (`useThreadReplies` closing the descendant gap) then fails on every
- // call against a real relay. So the thread filter MUST carry `kinds`,
- // and every kind MUST be non-p-gated (else the gate still fires). The
- // Playwright mock does not model p-gating, so this unit test is the
- // only guard against the client/relay auth contract drifting.
- let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
-
- let kinds = filter
- .get("kinds")
- .and_then(|v| v.as_array())
- .expect("thread filter must carry `kinds` so the p-gate passes");
- assert!(!kinds.is_empty(), "kinds must be non-empty");
- for kind in kinds {
- let k = kind.as_u64().expect("kind is a number") as u32;
- assert!(
- !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
- "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
- 403 that this fix exists to prevent"
- );
- }
- assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
- assert_eq!(filter["depth_limit"], serde_json::json!(64));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- }
-
- #[test]
- fn thread_replies_filter_pages_with_composite_cursor() {
- // When a cursor is supplied, both the timestamp and the event-id
- // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
- // paging degrades to timestamp-only and drops same-second replies.
- let cursor = crate::models::ThreadCursor {
- created_at: 1_700_000_000,
- event_id: "abcd".to_string(),
- };
- let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
- assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
- assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
- assert!(
- !filter.contains_key("#h"),
- "no channel_id -> no #h scope in the filter"
- );
- }
-
- #[test]
- fn stored_managed_agent_auth_tag_trims_blank_values() {
- assert_eq!(
- stored_managed_agent_auth_tag(Some(" [\"auth\",\"owner\",\"\",\"sig\"] ")),
- Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
- );
- assert_eq!(stored_managed_agent_auth_tag(Some(" ")), None);
- assert_eq!(stored_managed_agent_auth_tag(None), None);
- }
-
- #[test]
- fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
- let owner_keys = Keys::generate();
- let agent_keys = Keys::generate();
-
- let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
- .expect("legacy auth tag should compute")
- .expect("legacy auth tag should be present");
-
- let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
- .expect("legacy auth tag should verify");
- assert_eq!(owner, owner_keys.public_key());
- }
-
- #[test]
- fn legacy_managed_agent_auth_tag_skips_self_attestation() {
- let owner_keys = Keys::generate();
-
- let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
- .expect("self-attestation should be skipped");
-
- assert_eq!(tag, None);
- }
-}
-
#[tauri::command]
pub async fn add_reaction(
event_id: String,
@@ -1033,3 +953,6 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
category: category.to_string(),
}
}
+#[cfg(test)]
+#[path = "messages_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs
new file mode 100644
index 0000000000..3c08f0f113
--- /dev/null
+++ b/desktop/src-tauri/src/commands/messages_tests.rs
@@ -0,0 +1,193 @@
+use super::*;
+
+#[test]
+fn marker_author_scope_validates_scope_and_required_pubkey() {
+ assert_eq!(
+ marker_author_for_scope(None, Some("agent")),
+ Ok(Some("agent"))
+ );
+ assert_eq!(
+ marker_author_for_scope(Some("agent"), Some("agent")),
+ Ok(Some("agent"))
+ );
+ assert_eq!(marker_author_for_scope(Some("channel"), None), Ok(None));
+ assert_eq!(
+ marker_author_for_scope(Some("agent"), None),
+ Err("agent pubkey is required for agent-scoped markers".to_string())
+ );
+ assert_eq!(
+ marker_author_for_scope(None, None),
+ Err("agent pubkey is required for agent-scoped markers".to_string())
+ );
+ assert_eq!(
+ marker_author_for_scope(Some("unexpected"), Some("agent")),
+ Err("unsupported marker scope: unexpected".to_string())
+ );
+}
+
+#[test]
+fn managed_agent_message_builder_adds_mentions_and_client_marker() {
+ let pubkey = Keys::generate().public_key().to_hex();
+ let event = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ std::slice::from_ref(&pubkey),
+ &[vec!["client".to_string(), "welcome-v1".to_string()]],
+ )
+ .expect("message should build")
+ .sign_with_keys(&Keys::generate())
+ .expect("message should sign");
+
+ assert!(event.tags.iter().any(|tag| {
+ let parts = tag.as_slice();
+ parts.len() >= 2 && parts[0] == "p" && parts[1] == pubkey
+ }));
+ assert!(event_has_client_marker(&event, "welcome-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_can_carry_multiple_client_markers() {
+ let event = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ &[],
+ &[
+ vec!["client".to_string(), "opener-v1".to_string()],
+ vec!["client".to_string(), "closer-v1".to_string()],
+ ],
+ )
+ .expect("message should build")
+ .sign_with_keys(&Keys::generate())
+ .expect("message should sign");
+
+ assert!(event_has_client_marker(&event, "opener-v1"));
+ assert!(event_has_client_marker(&event, "closer-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_rejects_invalid_mentions() {
+ let error = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ &["not-a-pubkey".to_string()],
+ &[],
+ )
+ .expect_err("invalid mentions should fail");
+ assert!(error.contains("pubkey must be a 64-character hex string"));
+}
+#[test]
+fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
+ let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
+
+ assert_eq!(filter["search"], serde_json::json!("pro"));
+ assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
+ assert_eq!(filter["limit"], serde_json::json!(12));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
+ // The relay bridge's `extract_before_id` reads the composite tiebreak
+ // from `before_id`. If this filter sent the id under any other key (an
+ // earlier cut used `n`), the relay would silently drop the tiebreak and
+ // the dense-second keyset would degrade to a bare inclusive `until` —
+ // re-returning the same page forever. Pin the field name here so the
+ // client/relay contract can't drift without a red test (the Playwright
+ // mock reimplements the keyset in JS and cannot catch this).
+ let filter = build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
+
+ assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
+ assert_eq!(filter["before_id"], serde_json::json!("ab"));
+ assert_eq!(filter["limit"], serde_json::json!(200));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+ assert!(
+ !filter.contains_key("n"),
+ "tiebreak must be `before_id`, not the `n` alias the relay ignores"
+ );
+}
+
+#[test]
+fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
+ // The relay bridge p-gates EVERY filter before routing
+ // (`p_gated_filters_authorized`): a kindless filter "could match" a
+ // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
+ // before the thread-subtree query runs. The headline Lane-1 fix
+ // (`useThreadReplies` closing the descendant gap) then fails on every
+ // call against a real relay. So the thread filter MUST carry `kinds`,
+ // and every kind MUST be non-p-gated (else the gate still fires). The
+ // Playwright mock does not model p-gating, so this unit test is the
+ // only guard against the client/relay auth contract drifting.
+ let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
+
+ let kinds = filter
+ .get("kinds")
+ .and_then(|v| v.as_array())
+ .expect("thread filter must carry `kinds` so the p-gate passes");
+ assert!(!kinds.is_empty(), "kinds must be non-empty");
+ for kind in kinds {
+ let k = kind.as_u64().expect("kind is a number") as u32;
+ assert!(
+ !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
+ "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
+ 403 that this fix exists to prevent"
+ );
+ }
+ assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
+ assert_eq!(filter["depth_limit"], serde_json::json!(64));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn thread_replies_filter_pages_with_composite_cursor() {
+ // When a cursor is supplied, both the timestamp and the event-id
+ // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
+ // paging degrades to timestamp-only and drops same-second replies.
+ let cursor = crate::models::ThreadCursor {
+ created_at: 1_700_000_000,
+ event_id: "abcd".to_string(),
+ };
+ let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
+ assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
+ assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
+ assert!(
+ !filter.contains_key("#h"),
+ "no channel_id -> no #h scope in the filter"
+ );
+}
+
+#[test]
+fn stored_managed_agent_auth_tag_trims_blank_values() {
+ assert_eq!(
+ stored_managed_agent_auth_tag(Some(" [\"auth\",\"owner\",\"\",\"sig\"] ")),
+ Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
+ );
+ assert_eq!(stored_managed_agent_auth_tag(Some(" ")), None);
+ assert_eq!(stored_managed_agent_auth_tag(None), None);
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
+ let owner_keys = Keys::generate();
+ let agent_keys = Keys::generate();
+
+ let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
+ .expect("legacy auth tag should compute")
+ .expect("legacy auth tag should be present");
+
+ let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
+ .expect("legacy auth tag should verify");
+ assert_eq!(owner, owner_keys.public_key());
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_skips_self_attestation() {
+ let owner_keys = Keys::generate();
+
+ let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
+ .expect("self-attestation should be skipped");
+
+ assert_eq!(tag, None);
+}
diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs
index c602bdce42..cb17ad608e 100644
--- a/desktop/src-tauri/src/commands/workspace.rs
+++ b/desktop/src-tauri/src/commands/workspace.rs
@@ -103,6 +103,7 @@ pub async fn apply_workspace(
relay_url: String,
nsec: Option,
repos_dir: Option,
+ agent_managed_profiles: Option,
app: AppHandle,
) -> Result<(), String> {
let restore_app = app.clone();
@@ -148,6 +149,13 @@ pub async fn apply_workspace(
*keys_guard = keys;
}
+ // Keep the backend-side reconcile guard aligned with the frontend
+ // experiment before launch-time restore can spawn any agents. Missing
+ // means the stable behavior: desktop remains authoritative.
+ state
+ .managed_agent_profile_reconcile_enabled
+ .store(!agent_managed_profiles.unwrap_or(false), Ordering::Release);
+
// ── Filesystem side-effect (non-fatal) ────────────────────────────────
// Persist the *effective* repos_dir (None when the candidate failed
// validation) for the backend to read at boot, then re-point REPOS to
@@ -176,18 +184,45 @@ pub async fn apply_workspace(
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
let state = restore_app.state::();
- if state
+ let restore_pending = state
.managed_agent_restore_pending
- .swap(false, Ordering::AcqRel)
+ .swap(false, Ordering::AcqRel);
+
+ // The coordinator starts before React applies the selected workspace, so
+ // its startup publication may have used the fallback relay and placeholder
+ // identity. Correct it off the command path so an unavailable relay cannot
+ // hold the frontend on its loading gate. On initial launch, restore MeshLLM
+ // first so a slow stopped-status request cannot overwrite a newly restored
+ // serving status, then restore managed agents after the admission identity
+ // has been published (or the bounded publication attempt has timed out).
+ #[cfg(feature = "mesh-llm")]
{
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::();
- #[cfg(feature = "mesh-llm")]
- if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
- {
- eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
+ if restore_pending {
+ if let Err(error) =
+ crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
+ {
+ eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
+ }
}
+ crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
+ if restore_pending {
+ if let Err(error) =
+ restore_managed_agents_on_launch(&app, &state.shutdown_started).await
+ {
+ eprintln!("buzz-desktop: failed to restore managed agents: {error}");
+ }
+ }
+ });
+ }
+
+ #[cfg(not(feature = "mesh-llm"))]
+ if restore_pending {
+ let app = restore_app.clone();
+ tauri::async_runtime::spawn(async move {
+ let state = app.state::();
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 9a4eb3dcb1..dadd2198b8 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -811,6 +811,7 @@ pub fn run() {
search_messages,
send_channel_message,
send_managed_agent_channel_message,
+ has_managed_agent_channel_message_marker,
get_forum_posts,
get_forum_thread,
get_thread_replies,
@@ -847,6 +848,7 @@ pub fn run() {
create_managed_agent,
start_managed_agent,
stop_managed_agent,
+ set_agent_managed_profiles,
set_managed_agent_start_on_app_launch,
set_managed_agent_auto_restart,
delete_managed_agent,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 2c15813e48..31b31e00d0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1361,6 +1361,7 @@ pub fn build_managed_agent_summary(
pubkey: record.pubkey.clone(),
name: record.name.clone(),
persona_id: record.persona_id.clone(),
+ team_id: record.team_id.clone(),
relay_url: record.relay_url.clone(),
acp_command: record.acp_command.clone(),
agent_command: effective_command,
diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs
index 6ee8c0b2ed..937893d531 100644
--- a/desktop/src-tauri/src/managed_agents/teams.rs
+++ b/desktop/src-tauri/src/managed_agents/teams.rs
@@ -31,7 +31,12 @@ struct BuiltInTeam {
persona_ids: &'static [&'static str],
}
-const BUILT_IN_TEAMS: &[BuiltInTeam] = &[];
+const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam {
+ id: "builtin-team:welcome",
+ name: "Welcome Team",
+ description: Some("A friendly starter trio ready to help you plan, create, and ship."),
+ persona_ids: &["builtin:fizz", "builtin:honey", "builtin:bumble"],
+}];
// Built-in teams that have been retired. A stored copy that still exactly
// matches its seed is purged on load (the user never touched it); customized
diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs
index 7e9c7656b4..140ac3cab9 100644
--- a/desktop/src-tauri/src/managed_agents/teams_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs
@@ -317,12 +317,62 @@ fn migration_customized_fizz_is_demoted_to_user_team() {
}
#[test]
-fn migration_empty_store_stays_empty() {
- // Fresh installs see no teams and no spurious changed flag.
+fn welcome_team_is_seeded_and_idempotent() {
let (records, changed) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
+ assert!(changed);
+ assert_eq!(records.len(), 1);
+ let welcome = &records[0];
+ assert_eq!(welcome.id, "builtin-team:welcome");
+ assert_eq!(welcome.name, "Welcome Team");
+ assert_eq!(
+ welcome.description.as_deref(),
+ Some("A friendly starter trio ready to help you plan, create, and ship.")
+ );
+ assert_eq!(
+ welcome.persona_ids,
+ vec![
+ "builtin:fizz".to_string(),
+ "builtin:honey".to_string(),
+ "builtin:bumble".to_string(),
+ ]
+ );
+ assert!(welcome.is_builtin);
+
+ let expected = serde_json::to_value(&records).unwrap();
+ let (records_after_second_merge, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
assert!(!changed);
- assert!(records.is_empty());
+ assert_eq!(
+ serde_json::to_value(records_after_second_merge).unwrap(),
+ expected
+ );
+}
+
+#[test]
+fn welcome_team_seed_does_not_overwrite_customization() {
+ let (mut records, _) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
+ let welcome = records
+ .iter_mut()
+ .find(|team| team.id == "builtin-team:welcome")
+ .expect("welcome team should be seeded");
+ welcome.name = "My Welcome Team".to_string();
+ welcome.description = Some("My customized starter team.".to_string());
+ welcome.persona_ids = vec!["builtin:honey".to_string()];
+
+ let (records, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
+
+ assert!(!changed);
+ let welcome = records
+ .iter()
+ .find(|team| team.id == "builtin-team:welcome")
+ .expect("customized welcome team should be preserved");
+ assert_eq!(welcome.name, "My Welcome Team");
+ assert_eq!(
+ welcome.description.as_deref(),
+ Some("My customized starter team.")
+ );
+ assert_eq!(welcome.persona_ids, vec!["builtin:honey".to_string()]);
+ assert!(welcome.is_builtin);
}
// ── load_teams_readonly tests ──────────────────────────────────────────
@@ -337,9 +387,9 @@ fn load_teams_readonly_absent_file_performs_no_write() {
let records = load_teams_readonly(&path).unwrap();
- // Returns the merged built-in list (currently empty since BUILT_IN_TEAMS
- // is empty, but the function ran merge_teams + sort_teams).
- assert!(records.is_empty());
+ // Returns the merged built-in list without persisting it.
+ assert_eq!(records.len(), 1);
+ assert_eq!(records[0].id, "builtin-team:welcome");
// The file must still NOT exist — no write-on-load side effect.
assert!(
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index bb0781b643..7f69b77e0d 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -454,6 +454,7 @@ pub struct ManagedAgentSummary {
pub pubkey: String,
pub name: String,
pub persona_id: Option,
+ pub team_id: Option,
pub relay_url: String,
pub acp_command: String,
pub agent_command: String,
diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs
index d5fd91b905..385971cb86 100644
--- a/desktop/src-tauri/src/mesh_llm/catalog.rs
+++ b/desktop/src-tauri/src/mesh_llm/catalog.rs
@@ -8,10 +8,34 @@
use serde::Serialize;
use mesh_llm_client::models::catalog::{parse_size_gb, MODEL_CATALOG};
-use mesh_llm_client::network::nostr::auto_model_pack;
use mesh_llm_node::models::{default_huggingface_cache_dir, scan_installed_models};
use mesh_llm_system::hardware;
-use mesh_llm_system::vram::format_rated_capacity;
+use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb};
+
+/// Buzz-curated tier picks. These are the models we know survive the agent
+/// harness on shared compute — deliberately non-reasoning instruction models,
+/// so agents stay snappy instead of burning hidden reasoning tokens.
+///
+/// The large pick is resolved through mesh-llm's remote catalog
+/// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in
+/// the compiled `MODEL_CATALOG`; the entry is synthesized below.
+const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M";
+const CURATED_LARGE_SIZE: &str = "17GB";
+const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf";
+const CURATED_LARGE_DESCRIPTION: &str =
+ "Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines";
+const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M";
+/// Rated-capacity boundary between the two curated tiers, in GB (marketing
+/// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less).
+const CURATED_LARGE_MIN_RATED_GB: u64 = 64;
+
+/// The Buzz-curated recommendation for a machine's rated memory capacity.
+fn buzz_recommended_model(rated_gb: Option) -> &'static str {
+ match rated_gb {
+ Some(gb) if gb >= CURATED_LARGE_MIN_RATED_GB => CURATED_LARGE,
+ _ => CURATED_SMALL,
+ }
+}
/// How a model sits inside this machine's usable AI memory.
/// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds.
@@ -57,6 +81,9 @@ pub struct MeshCatalogEntry {
pub fit: ModelFit,
pub installed: bool,
pub recommended: bool,
+ /// Buzz-curated pick — known to survive the agent harness. Curated
+ /// entries render above the fold; everything else is "advanced".
+ pub curated: bool,
}
#[derive(Debug, Clone, Serialize)]
@@ -123,6 +150,7 @@ fn build_catalog(
fit: fit_code(size_gb, vram_gb),
installed: is_installed(&m.file, &m.name),
recommended: false,
+ curated: false,
name: m.name.clone(),
size: m.size.clone(),
size_gb,
@@ -131,14 +159,36 @@ fn build_catalog(
})
.collect();
- let recommended = auto_model_pack(vram_gb).into_iter().next();
+ // The compiled MODEL_CATALOG does not know the Buzz large pick; it
+ // resolves through mesh-llm's remote catalog at download time. Synthesize
+ // its entry so the picker can offer it.
+ if !entries.iter().any(|e| e.name == CURATED_LARGE) {
+ let size_gb = parse_size_gb(CURATED_LARGE_SIZE);
+ entries.push(MeshCatalogEntry {
+ fit: fit_code(size_gb, vram_gb),
+ installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE),
+ recommended: false,
+ curated: false,
+ name: CURATED_LARGE.to_string(),
+ size: CURATED_LARGE_SIZE.to_string(),
+ size_gb,
+ description: CURATED_LARGE_DESCRIPTION.to_string(),
+ });
+ }
+
+ let recommended = Some(buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string());
for entry in &mut entries {
entry.recommended = recommended.as_deref() == Some(entry.name.as_str());
+ // Both curated tiers are always offered: the recommended one for this
+ // machine plus the other pick (e.g. the small one as an explicit
+ // lighter choice on big machines).
+ entry.curated = entry.name == CURATED_LARGE || entry.name == CURATED_SMALL;
}
entries.sort_by(|a, b| {
b.recommended
.cmp(&a.recommended)
+ .then(b.curated.cmp(&a.curated))
.then(fit_rank(a.fit).cmp(&fit_rank(b.fit)))
.then(b.size_gb.total_cmp(&a.size_gb))
});
@@ -191,11 +241,11 @@ mod tests {
assert!(catalog.entries[0].recommended);
}
}
- // Fit ranks must be non-decreasing after the recommended head.
+ // Fit ranks must be non-decreasing after the recommended/curated head.
let ranks: Vec = catalog
.entries
.iter()
- .skip_while(|e| e.recommended)
+ .skip_while(|e| e.recommended || e.curated)
.map(|e| fit_rank(e.fit))
.collect();
assert!(
@@ -205,12 +255,32 @@ mod tests {
}
#[test]
- fn recommendation_uses_mesh_llm_auto_selection() {
- let catalog = build_catalog(None, 62_000_000_000, 62.0, &[]);
- assert_eq!(
- catalog.recommended,
- auto_model_pack(62.0).into_iter().next()
- );
+ fn recommendation_follows_buzz_curated_tiers() {
+ // 64GB+ rated machines get the large curated pick.
+ let large = build_catalog(None, 64_000_000_000, 64.0, &[]);
+ assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE));
+ let big = build_catalog(None, 128_000_000_000, 128.0, &[]);
+ assert_eq!(big.recommended.as_deref(), Some(CURATED_LARGE));
+ // Below the boundary: the small curated pick — never a reasoning
+ // model, never sub-4B guesswork.
+ let small = build_catalog(None, 32_000_000_000, 32.0, &[]);
+ assert_eq!(small.recommended.as_deref(), Some(CURATED_SMALL));
+ let tiny = build_catalog(None, 16_000_000_000, 16.0, &[]);
+ assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL));
+ }
+
+ #[test]
+ fn curated_picks_lead_the_catalog() {
+ let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]);
+ // Recommended curated entry first, the other curated pick second,
+ // advanced entries after.
+ assert_eq!(catalog.entries[0].name, CURATED_LARGE);
+ assert!(catalog.entries[0].recommended && catalog.entries[0].curated);
+ assert_eq!(catalog.entries[1].name, CURATED_SMALL);
+ assert!(catalog.entries[1].curated && !catalog.entries[1].recommended);
+ assert!(catalog.entries[2..].iter().all(|e| !e.curated));
+ // The synthesized large pick carries a real size for fit ranking.
+ assert!(catalog.entries[0].size_gb > 10.0);
}
#[test]
diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs
index 8e7b2f9536..e83bf8b280 100644
--- a/desktop/src-tauri/src/mesh_llm/coordinator.rs
+++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs
@@ -21,6 +21,7 @@ pub const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = buzz_core_pkg::kind::KIND_BOOKMARK
const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status";
const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60);
const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45);
+const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
pub struct MeshCoordinator {
_status_publisher: tokio::task::JoinHandle<()>,
@@ -38,21 +39,25 @@ pub async fn start_coordinator(app: AppHandle) {
let publisher_app = app.clone();
let status_publisher = tokio::spawn(async move {
- // Clear a stale serving status promptly after an app restart. Once the
- // node is stopped, the explicit stopped event is sufficient; only a
- // running node needs periodic freshness heartbeats.
+ // Clear a stale serving status promptly after an app restart. Keep
+ // publishing while stopped too: serving nodes build their admission
+ // allowlist from fresh member statuses, so consumer-only identities
+ // must remain fresh even though they advertise no serving targets.
publish_current_status_once(&publisher_app, "startup").await;
loop {
tokio::time::sleep(STATUS_PUBLISH_INTERVAL).await;
- publish_running_status_once(&publisher_app).await;
+ publish_current_status_once(&publisher_app, "heartbeat").await;
}
});
let roster_app = app.clone();
let roster_watcher = tokio::spawn(async move {
+ // Carries a shrink awaiting confirmation across polls (hysteresis):
+ // a reduced roster must be seen twice in a row before we tear down.
+ let mut pending_shrink: Option> = None;
loop {
tokio::time::sleep(ROSTER_POLL_INTERVAL).await;
let state = roster_app.state::();
- if let Err(error) = reconcile_roster(&state).await {
+ if let Err(error) = reconcile_roster(&state, &mut pending_shrink).await {
eprintln!("buzz-mesh: roster reconcile failed: {error}");
}
}
@@ -71,21 +76,108 @@ pub async fn start_coordinator(app: AppHandle) {
}
}
-async fn reconcile_roster(state: &AppState) -> Result<(), String> {
+/// Outcome of a roster reconcile decision.
+#[derive(Debug, PartialEq, Eq)]
+enum RosterReconcileAction {
+ /// Keep the running allowlist untouched (no-op, or a failure we ride out).
+ Keep,
+ /// Restart the node with a freshly resolved roster.
+ Restart(Vec),
+ /// Observed a *shrink* (or empty) once. Hold the current allowlist and
+ /// require the same reduced roster on the next poll before tearing down,
+ /// so a single transient short-read never drops a member mid-inference.
+ AwaitConfirm(Vec),
+}
+
+/// Whether `fresh` removes any owner present in `current` (a shrink), as
+/// opposed to purely adding owners or leaving the set unchanged.
+fn roster_shrinks(current: &[String], fresh: &[String]) -> bool {
+ current.iter().any(|owner| !fresh.contains(owner))
+}
+
+/// Pure decision for `reconcile_roster`, extracted so the transient-failure
+/// and hysteresis invariants are unit-testable without a live relay.
+///
+/// `pending_shrink` is the reduced roster we are waiting to re-confirm (from a
+/// prior poll's [`RosterReconcileAction::AwaitConfirm`]), if any.
+///
+/// Rules:
+/// - query failed (`Err`) → `Keep` (never de-admit on a relay blip)
+/// - resolved roster == current → `Keep` (no-op)
+/// - grows (only additions) → `Restart` immediately (fast admission)
+/// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll)
+/// - shrinks/empties, confirmed → `Restart` (same reduced roster twice)
+fn roster_reconcile_action(
+ current_owners: &[String],
+ pending_shrink: Option<&[String]>,
+ query: Result, String>,
+) -> RosterReconcileAction {
+ let fresh = match query {
+ Err(error) => {
+ eprintln!(
+ "buzz-mesh: roster reconcile query failed; keeping current allowlist: {error}"
+ );
+ return RosterReconcileAction::Keep;
+ }
+ Ok(fresh) => fresh,
+ };
+
+ if fresh == current_owners {
+ return RosterReconcileAction::Keep;
+ }
+
+ // Growth (pure additions) is safe to apply immediately.
+ if !roster_shrinks(current_owners, &fresh) {
+ return RosterReconcileAction::Restart(fresh);
+ }
+
+ // A shrink (including down to empty) must be confirmed across two
+ // consecutive polls with the *same* reduced roster before we tear down.
+ match pending_shrink {
+ Some(pending) if pending == fresh => RosterReconcileAction::Restart(fresh),
+ _ => RosterReconcileAction::AwaitConfirm(fresh),
+ }
+}
+
+async fn reconcile_roster(
+ state: &AppState,
+ pending_shrink: &mut Option>,
+) -> Result<(), String> {
let current_request = {
let runtime = state.mesh_llm_runtime.lock().await;
match runtime.as_ref() {
Some(runtime) => runtime.start_request().clone(),
- None => return Ok(()),
+ None => {
+ *pending_shrink = None;
+ return Ok(());
+ }
}
};
let Some(current_owners) = current_request.trusted_owner_ids.as_ref() else {
+ *pending_shrink = None;
return Ok(());
};
- let fresh = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await;
- if &fresh == current_owners {
- return Ok(());
- }
+ // A failed roster query must NOT be treated as "the roster became empty":
+ // doing so would restart the node down to self-only and de-admit every
+ // other member on a transient relay blip (the flapping restart loop). Keep
+ // the current allowlist and try again on the next poll. A shrink is held
+ // for one extra poll (hysteresis) so a single short-read never tears down.
+ let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await;
+ let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) {
+ RosterReconcileAction::Keep => {
+ *pending_shrink = None;
+ return Ok(());
+ }
+ RosterReconcileAction::AwaitConfirm(reduced) => {
+ eprintln!("buzz-mesh: roster shrink observed; awaiting confirmation before restart");
+ *pending_shrink = Some(reduced);
+ return Ok(());
+ }
+ RosterReconcileAction::Restart(fresh) => {
+ *pending_shrink = None;
+ fresh
+ }
+ };
let mut request = current_request;
request.trusted_owner_ids = Some(fresh);
@@ -106,22 +198,31 @@ async fn reconcile_roster(state: &AppState) -> Result<(), String> {
pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) {
let state = app.state::();
- if let Err(error) = publish_current_status_for_state(&state).await {
- eprintln!("buzz-mesh: status report after {reason} failed: {error}");
+ match tokio::time::timeout(
+ STATUS_PUBLISH_TIMEOUT,
+ publish_current_status_for_state(&state),
+ )
+ .await
+ {
+ Ok(Ok(())) => {}
+ Ok(Err(error)) => eprintln!("buzz-mesh: status report after {reason} failed: {error}"),
+ Err(_) => eprintln!("buzz-mesh: status report after {reason} timed out"),
}
}
pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) {
let state = app.state::();
- if let Err(error) = publish_stopped_status_for_state(&state).await {
- eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
- }
-}
-
-async fn publish_running_status_once(app: &AppHandle) {
- let state = app.state::();
- if let Err(error) = publish_running_status_for_state(&state).await {
- eprintln!("buzz-mesh: periodic status report failed: {error}");
+ match tokio::time::timeout(
+ STATUS_PUBLISH_TIMEOUT,
+ publish_stopped_status_for_state(&state),
+ )
+ .await
+ {
+ Ok(Ok(())) => {}
+ Ok(Err(error)) => {
+ eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
+ }
+ Err(_) => eprintln!("buzz-mesh: stopped status report after {reason} timed out"),
}
}
@@ -150,23 +251,6 @@ async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String
publish_status_report(state, payload).await
}
-async fn publish_running_status_for_state(state: &AppState) -> Result<(), String> {
- let identity = super::ensure_owner_identity()
- .map_err(|error| format!("failed to load mesh owner identity: {error}"))?;
- let mut payload = {
- let runtime = state.mesh_llm_runtime.lock().await;
- let Some(runtime) = runtime.as_ref() else {
- return Ok(());
- };
- runtime
- .status_report_payload()
- .await
- .map_err(|error| error.to_string())?
- };
- bind_payload_to_member(state, &identity, &mut payload)?;
- publish_status_report(state, payload).await
-}
-
fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value {
serde_json::json!({
"ownerId": identity.owner_id,
@@ -233,11 +317,97 @@ mod tests {
use super::*;
+ // Regression: a transient roster-query failure must never restart the node
+ // down to self-only. Before the fix, `resolve_trusted_owner_ids` returned
+ // an empty Vec on error, which `reconcile_roster` read as "roster changed
+ // to empty" and restarted — de-admitting every other member and flapping
+ // the node on each relay blip. See #2000 follow-up.
#[test]
- fn heartbeat_leaves_room_before_status_expires() {
+ fn failed_roster_query_keeps_current_allowlist() {
+ let current = vec!["owner-a".to_string(), "owner-b".to_string()];
+ let action = roster_reconcile_action(¤t, None, Err("relay returned 503".to_string()));
+ assert_eq!(
+ action,
+ RosterReconcileAction::Keep,
+ "a failed query must keep the running allowlist, never de-admit members"
+ );
+ }
+
+ #[test]
+ fn unchanged_roster_is_a_noop() {
+ let current = vec!["owner-a".to_string()];
+ let action = roster_reconcile_action(¤t, None, Ok(vec!["owner-a".to_string()]));
+ assert_eq!(action, RosterReconcileAction::Keep);
+ }
+
+ // Growth (pure additions) applies immediately — fast admission is fine.
+ #[test]
+ fn roster_growth_restarts_immediately() {
+ let current = vec!["owner-a".to_string()];
+ let fresh = vec!["owner-a".to_string(), "owner-c".to_string()];
+ let action = roster_reconcile_action(¤t, None, Ok(fresh.clone()));
+ assert_eq!(action, RosterReconcileAction::Restart(fresh));
+ }
+
+ // A shrink is NOT applied on first observation — it must be confirmed.
+ #[test]
+ fn roster_shrink_awaits_confirmation_first() {
+ let current = vec!["owner-a".to_string(), "owner-b".to_string()];
+ let reduced = vec!["owner-a".to_string()];
+ let action = roster_reconcile_action(¤t, None, Ok(reduced.clone()));
+ assert_eq!(action, RosterReconcileAction::AwaitConfirm(reduced));
+ }
+
+ // The same reduced roster on two consecutive polls confirms the shrink.
+ #[test]
+ fn roster_shrink_restarts_once_confirmed() {
+ let current = vec!["owner-a".to_string(), "owner-b".to_string()];
+ let reduced = vec!["owner-a".to_string()];
+ let action = roster_reconcile_action(¤t, Some(&reduced), Ok(reduced.clone()));
+ assert_eq!(action, RosterReconcileAction::Restart(reduced));
+ }
+
+ // A shrink that changes between polls is not confirmed — it re-holds with
+ // the newly observed reduced roster instead of tearing down.
+ #[test]
+ fn roster_shrink_reconfirms_when_it_changes() {
+ let current = vec!["a".to_string(), "b".to_string(), "c".to_string()];
+ let first_reduced = vec!["a".to_string(), "b".to_string()];
+ let second_reduced = vec!["a".to_string()];
+ let action =
+ roster_reconcile_action(¤t, Some(&first_reduced), Ok(second_reduced.clone()));
+ assert_eq!(action, RosterReconcileAction::AwaitConfirm(second_reduced));
+ }
+
+ // A genuinely empty community (Ok(empty), distinct from a failed query)
+ // still shrinks to self-only — but only after confirmation.
+ #[test]
+ fn genuinely_empty_roster_awaits_then_restarts_to_self_only() {
+ let current = vec!["owner-a".to_string()];
+ let first = roster_reconcile_action(¤t, None, Ok(Vec::new()));
+ assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new()));
+ let empty: Vec = Vec::new();
+ let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Vec::new()));
+ assert_eq!(confirmed, RosterReconcileAction::Restart(Vec::new()));
+ }
+
+ // A shrink followed by recovery to the full roster cancels the teardown.
+ #[test]
+ fn roster_shrink_then_recovery_keeps_allowlist() {
+ let current = vec!["owner-a".to_string(), "owner-b".to_string()];
+ let reduced = vec!["owner-a".to_string()];
+ let held = roster_reconcile_action(¤t, None, Ok(reduced.clone()));
+ assert_eq!(held, RosterReconcileAction::AwaitConfirm(reduced.clone()));
+ let recovered = roster_reconcile_action(¤t, Some(&reduced), Ok(current.clone()));
+ assert_eq!(recovered, RosterReconcileAction::Keep);
+ }
+
+ #[test]
+ fn member_heartbeat_leaves_room_before_admission_status_expires() {
assert!(
STATUS_PUBLISH_INTERVAL.as_secs() * 2 < super::super::discovery::STATUS_FRESHNESS_SECS
);
+ assert!(STATUS_PUBLISH_TIMEOUT < STATUS_PUBLISH_INTERVAL);
}
#[test]
diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs
index 3907a2731e..d7dca6b11b 100644
--- a/desktop/src-tauri/src/mesh_llm/discovery.rs
+++ b/desktop/src-tauri/src/mesh_llm/discovery.rs
@@ -9,6 +9,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M
/// than two minutes so crashed/offline devices stop contributing compute or
/// admission identities without requiring a relay-side cleanup job.
pub(super) const STATUS_FRESHNESS_SECS: u64 = 120;
+pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100;
fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
event
@@ -34,16 +35,22 @@ fn dedupe_targets(targets: Vec) -> Vec {
/// contribute an owner id. This removes stale notes from former members and
/// ignores notes from nonmembers. If the relay has no membership snapshot, the
/// roster is empty and MeshLLM admission therefore remains self-only.
+///
+/// Admission deliberately ignores status freshness: membership is the trust
+/// boundary, and a member whose device is offline (stale status) is still a
+/// member. Gating admission on freshness caused the allowlist — and therefore
+/// the serving node — to churn whenever any member's app went online or
+/// offline. Freshness still gates *routing* (see `availability_from_events`):
+/// stale nodes are never selected as serve targets. Revocation is unaffected:
+/// a member removed from the NIP-43 roster leaves the intersection at the next
+/// roster poll regardless of how fresh their last status is.
pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec {
let Some(members) = latest_membership_list(events) else {
return Vec::new();
};
- let now = nostr::Timestamp::now().as_secs();
let mut ids: Vec = events
.iter()
- .filter(|event| {
- event.kind.as_u16() as u64 == MESH_STATUS_KIND && status_is_fresh(event, now)
- })
+ .filter(|event| event.kind.as_u16() as u64 == MESH_STATUS_KIND)
.filter(|event| {
reporter_pubkey_from_status_event(event)
.is_some_and(|reporter| members.contains(&reporter.to_ascii_lowercase()))
@@ -79,6 +86,27 @@ fn latest_membership_list(events: &[nostr::Event]) -> Option> {
})
}
+pub(crate) fn current_member_pubkeys(events: &[nostr::Event]) -> Vec {
+ latest_membership_list(events)
+ .map(BTreeSet::into_iter)
+ .map(Iterator::collect)
+ .unwrap_or_default()
+}
+
+/// Whether the relay actually returned a NIP-43 membership snapshot (kind
+/// 13534) in `events`.
+///
+/// The relay publishes an explicit membership event even for a zero-member
+/// community, so its presence is what makes an empty roster *authoritative*.
+/// Callers use this to distinguish "the community genuinely has no members"
+/// (snapshot present, zero `member` tags) from "no snapshot came back at all"
+/// (a transient relay gap / replication lag). Only the former may shrink the
+/// admission roster; the latter must be surfaced as an error so the reconcile
+/// loop keeps the current allowlist instead of restarting to self-only.
+pub(crate) fn has_membership_snapshot(events: &[nostr::Event]) -> bool {
+ events.iter().any(|event| event.kind.as_u16() == 13_534)
+}
+
fn owner_id_from_status_event(event: &nostr::Event) -> Option {
let content = serde_json::from_str::(&event.content).ok()?;
let owner_id = content
@@ -186,11 +214,12 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability {
.unwrap_or_default()
.into_iter()
.filter_map(|mut target| {
- let endpoint_id =
+ let validated =
super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr)
.ok()?;
+ target.endpoint_addr = validated.join_token;
if target.endpoint_id.is_none() {
- target.endpoint_id = Some(endpoint_id);
+ target.endpoint_id = Some(validated.endpoint_id);
}
if target.device_id.is_none() {
target.device_id = target.endpoint_id.clone();
@@ -242,11 +271,19 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability {
}
}
+/// Status filter for admission and availability queries.
+///
+/// Deliberately has no `since` bound: status events are parameterized
+/// replaceable (one per member), and admission must see a member's latest
+/// owner binding even when that member has been offline for longer than
+/// [`STATUS_FRESHNESS_SECS`]. Freshness is applied *after* the query, and only
+/// where it belongs — routing (`availability_from_events`), never admission
+/// (`owner_ids_from_events`).
pub fn mesh_status_filter() -> serde_json::Value {
serde_json::json!({
"kinds": [MESH_STATUS_KIND],
"#k": ["buzz-mesh-status"],
- "limit": 100
+ "limit": MESH_STATUS_PAGE_SIZE
})
}
@@ -300,7 +337,9 @@ pub(super) fn device_name_from_status(
}
fn endpoint_id_from_invite_token(invite_token: &str) -> Option {
- super::transport_policy::validate_advertised_endpoint(invite_token).ok()
+ super::transport_policy::validate_advertised_endpoint(invite_token)
+ .ok()
+ .map(|validated| validated.endpoint_id)
}
fn string_value(value: &serde_json::Value, key: &str) -> Option {
diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs
index a75fba1f16..80d887bec7 100644
--- a/desktop/src-tauri/src/mesh_llm/mod.rs
+++ b/desktop/src-tauri/src/mesh_llm/mod.rs
@@ -8,6 +8,9 @@ mod discovery;
pub use discovery::{
availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter,
};
+pub(crate) use discovery::{
+ current_member_pubkeys, has_membership_snapshot, MESH_STATUS_PAGE_SIZE,
+};
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
mod catalog;
@@ -269,8 +272,8 @@ async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> {
}
impl DesktopMeshRuntime {
- pub async fn start(request: StartMeshNodeRequest) -> anyhow::Result {
- validate_no_leak_request(&request)?;
+ pub async fn start(mut request: StartMeshNodeRequest) -> anyhow::Result {
+ sanitize_no_leak_request(&mut request)?;
initialize_mesh_native_runtime().await?;
let model_id = request
.model_id
@@ -419,8 +422,8 @@ impl DesktopMeshRuntime {
pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into) -> anyhow::Result<()> {
let endpoint_addr = endpoint_addr.into();
- validate_advertised_endpoint(&endpoint_addr)?;
- self.handle.join_token(endpoint_addr).await
+ let validated = validate_advertised_endpoint(&endpoint_addr)?;
+ self.handle.join_token(validated.join_token).await
}
pub async fn installed_models(&self) -> anyhow::Result> {
@@ -502,9 +505,9 @@ fn normalized_roster(
Some(owners)
}
-fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> {
- if let Some(join_token) = request.join_token.as_deref() {
- validate_advertised_endpoint(join_token)?;
+fn sanitize_no_leak_request(request: &mut StartMeshNodeRequest) -> anyhow::Result<()> {
+ if let Some(join_token) = request.join_token.as_mut() {
+ *join_token = validate_advertised_endpoint(join_token)?.join_token;
}
Ok(())
}
diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs
index 49897ce65e..e169af8886 100644
--- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs
+++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs
@@ -214,6 +214,34 @@ fn signed_membership_event(members: &[String]) -> nostr::Event {
.expect("test membership event signs")
}
+#[test]
+fn has_membership_snapshot_distinguishes_empty_from_missing() {
+ // A zero-member community still publishes an explicit kind:13534 event, so
+ // its presence — not the member count — is what makes an empty roster
+ // authoritative. No snapshot at all means the query was incomplete.
+ let zero_member_snapshot = signed_membership_event(&[]);
+ assert!(
+ super::has_membership_snapshot(std::slice::from_ref(&zero_member_snapshot)),
+ "an explicit zero-member snapshot counts as present"
+ );
+
+ let member = nostr::Keys::parse(&"1".repeat(64))
+ .unwrap()
+ .public_key()
+ .to_hex();
+ let populated = signed_membership_event(std::slice::from_ref(&member));
+ assert!(super::has_membership_snapshot(std::slice::from_ref(
+ &populated
+ )));
+
+ // A response with only status events (or nothing) has no snapshot.
+ assert!(!super::has_membership_snapshot(&[]));
+ let status_only = signed_reporter_status(&"2".repeat(64), "owner-x");
+ assert!(!super::has_membership_snapshot(std::slice::from_ref(
+ &status_only
+ )));
+}
+
#[test]
fn owner_ids_from_events_collects_sorted_deduped_roster() {
let secret_a = "1".repeat(64);
@@ -388,20 +416,45 @@ fn signed_reporter_target(reporter_secret: &str, model: &str, endpoint: &str) ->
}
#[test]
-fn stale_status_is_excluded_from_admission_and_availability() {
+fn stale_status_keeps_member_admitted_but_excluded_from_routing() {
let secret = "8".repeat(64);
let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex();
let stale = signed_reporter_target_at(&secret, "stale-model", &test_endpoint_token(), 1_000);
- // No newer event is required to age this status out. Freshness is measured
- // against wall clock, so an entirely offline mesh cannot remain live forever.
+ // Membership is the trust boundary: a current member whose device went
+ // offline (stale status) must stay admitted, otherwise every app
+ // open/close in the community churns the allowlist and restarts serving
+ // nodes. Freshness still gates routing: a stale node is never selected as
+ // a serve target.
let membership = signed_membership_event_at(std::slice::from_ref(&member), 900);
let events = vec![stale, membership];
- assert!(super::owner_ids_from_events(&events).is_empty());
+ assert_eq!(super::owner_ids_from_events(&events).len(), 1);
let availability = super::availability_from_events(events);
assert!(availability.serve_targets.is_empty());
}
+#[test]
+fn removed_member_is_dropped_from_admission_despite_fresh_status() {
+ // Revocation path: freshness must never resurrect trust. A reporter with a
+ // perfectly fresh status who is absent from the latest NIP-43 roster gets
+ // no admission entry.
+ let member_secret = "8".repeat(64);
+ let outsider_secret = "9".repeat(64);
+ let member = nostr::Keys::parse(&member_secret)
+ .unwrap()
+ .public_key()
+ .to_hex();
+ let now = nostr::Timestamp::now().as_secs();
+ let fresh_outsider =
+ signed_reporter_target_at(&outsider_secret, "model", &test_endpoint_token(), now);
+ // Latest roster lists only `member`; the outsider was removed (or never
+ // admitted).
+ let membership = signed_membership_event_at(std::slice::from_ref(&member), now);
+ let events = vec![fresh_outsider, membership];
+
+ assert!(super::owner_ids_from_events(&events).is_empty());
+}
+
#[test]
fn one_endpoint_can_advertise_multiple_models() {
let secret = "a".repeat(64);
diff --git a/desktop/src-tauri/src/mesh_llm/transport_policy.rs b/desktop/src-tauri/src/mesh_llm/transport_policy.rs
index e4e7ca9fe1..122bffd60e 100644
--- a/desktop/src-tauri/src/mesh_llm/transport_policy.rs
+++ b/desktop/src-tauri/src/mesh_llm/transport_policy.rs
@@ -72,7 +72,15 @@ fn parse_configured_relay_url(raw: &str) -> anyhow::Result {
.map_err(|error| anyhow::anyhow!("invalid iroh relay URL {raw:?}: {error}"))
}
-pub(super) fn validate_advertised_endpoint(invite_token: &str) -> anyhow::Result {
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct ValidatedEndpoint {
+ pub endpoint_id: String,
+ pub join_token: String,
+}
+
+pub(super) fn validate_advertised_endpoint(
+ invite_token: &str,
+) -> anyhow::Result {
let mode = iroh_relay_mode()?;
validate_advertised_endpoint_with_mode(invite_token, &mode)
}
@@ -80,7 +88,7 @@ pub(super) fn validate_advertised_endpoint(invite_token: &str) -> anyhow::Result
pub(super) fn validate_advertised_endpoint_with_mode(
invite_token: &str,
mode: &IrohRelayMode,
-) -> anyhow::Result {
+) -> anyhow::Result {
let token = invite_token.trim();
if token.is_empty() {
anyhow::bail!("mesh invite token is empty");
@@ -92,59 +100,139 @@ pub(super) fn validate_advertised_endpoint_with_mode(
.decode(token)
.map_err(|error| anyhow::anyhow!("invalid mesh invite encoding: {error}"))?;
- let addrs = if let Ok(addr) = serde_json::from_slice::(&payload) {
- vec![addr]
- } else {
- let signed = serde_json::from_slice::(&payload)
- .map_err(|error| anyhow::anyhow!("invalid mesh invite payload: {error}"))?;
- signed
- .verify()
- .map_err(|reason| anyhow::anyhow!("invalid signed mesh invite: {}", reason.code()))?;
- if signed.serialized_addrs.is_empty() || signed.serialized_addrs.len() > MAX_BOOTSTRAP_ADDRS
- {
- anyhow::bail!(
- "signed mesh invite must contain 1..={MAX_BOOTSTRAP_ADDRS} endpoint addresses"
- );
- }
- signed
- .serialized_addrs
- .iter()
- .map(|bytes| {
- serde_json::from_slice::(bytes)
- .map_err(|error| anyhow::anyhow!("invalid signed endpoint address: {error}"))
- })
- .collect::>>()?
- };
-
- for addr in &addrs {
- validate_endpoint_addr(addr, mode)?;
+ if let Ok(mut addr) = serde_json::from_slice::(&payload) {
+ retain_usable_transports(&mut addr, mode)?;
+ let endpoint_id = addr.id.to_string();
+ let join_token = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&addr)?);
+ return Ok(ValidatedEndpoint {
+ endpoint_id,
+ join_token,
+ });
}
- Ok(addrs[0].id.to_string())
+
+ let signed = serde_json::from_slice::(&payload)
+ .map_err(|error| anyhow::anyhow!("invalid mesh invite payload: {error}"))?;
+ signed
+ .verify()
+ .map_err(|reason| anyhow::anyhow!("invalid signed mesh invite: {}", reason.code()))?;
+ if signed.serialized_addrs.is_empty() || signed.serialized_addrs.len() > MAX_BOOTSTRAP_ADDRS {
+ anyhow::bail!(
+ "signed mesh invite must contain 1..={MAX_BOOTSTRAP_ADDRS} endpoint addresses"
+ );
+ }
+ let addrs = signed
+ .serialized_addrs
+ .iter()
+ .map(|bytes| {
+ serde_json::from_slice::(bytes)
+ .map_err(|error| anyhow::anyhow!("invalid signed endpoint address: {error}"))
+ })
+ .collect::>>()?;
+
+ // Rewriting a signed token would invalidate its signature. Keep the signed
+ // envelope intact only when every advertised transport is policy-approved;
+ // mixed signed tokens fail closed rather than leaking rejected dial targets.
+ for addr in &addrs {
+ validate_signed_transports(addr, mode)?;
+ }
+ Ok(ValidatedEndpoint {
+ endpoint_id: addrs[0].id.to_string(),
+ join_token: token.to_string(),
+ })
}
-fn validate_endpoint_addr(addr: &EndpointAddr, mode: &IrohRelayMode) -> anyhow::Result<()> {
+fn retain_usable_transports(addr: &mut EndpointAddr, mode: &IrohRelayMode) -> anyhow::Result<()> {
+ validate_transport_count(addr)?;
+ let mut rejections = Vec::new();
+ addr.addrs
+ .retain(|transport| match validate_transport(transport, mode) {
+ Ok(()) => true,
+ Err(error) => {
+ rejections.push(error.to_string());
+ false
+ }
+ });
+ if addr.addrs.is_empty() {
+ anyhow::bail!(
+ "mesh endpoint has no usable transport address (all rejected: {})",
+ rejections.join("; ")
+ );
+ }
+ Ok(())
+}
+
+fn validate_signed_transports(addr: &EndpointAddr, mode: &IrohRelayMode) -> anyhow::Result<()> {
+ validate_transport_count(addr)?;
+ let mut usable = 0usize;
+ for transport in &addr.addrs {
+ match validate_transport(transport, mode) {
+ Ok(()) => usable += 1,
+ // mesh-llm currently signs its own port-0 placeholder alongside a
+ // valid relay. It is non-dialable, so preserving it is safe and
+ // necessary for stock signed tokens to remain usable.
+ Err(_) if matches!(transport, TransportAddr::Ip(socket) if socket.port() == 0) => {}
+ Err(error) => return Err(error),
+ }
+ }
+ if usable == 0 {
+ anyhow::bail!("signed mesh endpoint has no usable transport address");
+ }
+ Ok(())
+}
+
+fn validate_transport_count(addr: &EndpointAddr) -> anyhow::Result<()> {
if addr.addrs.is_empty() || addr.addrs.len() > MAX_ENDPOINT_TRANSPORT_ADDRS {
anyhow::bail!(
"mesh endpoint must contain 1..={MAX_ENDPOINT_TRANSPORT_ADDRS} transport addresses"
);
}
- for transport in &addr.addrs {
- match transport {
- TransportAddr::Relay(relay) if relay_allowed(relay, mode) => {}
- TransportAddr::Relay(relay) => {
- anyhow::bail!("mesh endpoint advertises unapproved relay URL {relay}")
- }
- TransportAddr::Ip(socket) => validate_direct_socket(*socket)?,
- _ => anyhow::bail!("mesh endpoint contains an unsupported transport address"),
- }
- }
Ok(())
}
+fn validate_transport(transport: &TransportAddr, mode: &IrohRelayMode) -> anyhow::Result<()> {
+ match transport {
+ TransportAddr::Relay(relay) if relay_allowed(relay, mode) => Ok(()),
+ TransportAddr::Relay(relay) => anyhow::bail!("unapproved relay URL {relay}"),
+ TransportAddr::Ip(socket) => validate_direct_socket(*socket),
+ _ => anyhow::bail!("unsupported transport address"),
+ }
+}
+
+/// mesh-llm's default public relay set (`RelayPolicy::DefaultPublic` in
+/// `mesh-llm-host-runtime`). A stock mesh-llm server with no custom relay
+/// config advertises endpoints on exactly these relays, so buzz's `Default`
+/// mode MUST accept them — otherwise shared compute rejects every out-of-the-box
+/// mesh-llm serving node (they are not in iroh's own prod relay map).
+///
+/// Kept in sync with `effective_relay_urls(RelayPolicy::DefaultPublic, &[])`.
+const MESH_LLM_DEFAULT_RELAYS: &[&str] = &[
+ "https://usw1-2.relay.michaelneale.mesh-llm.iroh.link./",
+ "https://aps1-1.relay.michaelneale.mesh-llm.iroh.link./",
+];
+
+/// Whether `relay` is one of mesh-llm's baked-in default public relays.
+/// Parses each known URL to a `RelayUrl` so comparison is normalization-safe
+/// (matches regardless of trailing-dot / trailing-slash formatting).
+fn is_mesh_llm_default_relay(relay: &RelayUrl) -> bool {
+ MESH_LLM_DEFAULT_RELAYS.iter().any(|candidate| {
+ candidate
+ .parse::()
+ .map(|known| &known == relay)
+ .unwrap_or(false)
+ })
+}
+
fn relay_allowed(relay: &RelayUrl, mode: &IrohRelayMode) -> bool {
match mode {
IrohRelayMode::Disabled => false,
- IrohRelayMode::Default => iroh::defaults::prod::default_relay_map().contains(relay),
+ // `Default` covers both iroh's own production relays AND mesh-llm's
+ // default public relays. Without the latter, a stock mesh-llm serving
+ // node is unreachable by default and shared compute silently fails with
+ // "no live member is serving this model" even though discovery found it.
+ IrohRelayMode::Default => {
+ iroh::defaults::prod::default_relay_map().contains(relay)
+ || is_mesh_llm_default_relay(relay)
+ }
IrohRelayMode::Custom(urls) => urls.contains(relay),
}
}
@@ -213,6 +301,76 @@ mod tests {
assert!(validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Disabled).is_err());
}
+ #[test]
+ fn default_mode_accepts_meshllm_default_relays() {
+ // Regression: a stock mesh-llm serving node advertises endpoints on
+ // mesh-llm's OWN default public relays (not iroh's prod relay map).
+ // Under `Default` mode these MUST be accepted, or shared compute rejects
+ // every out-of-the-box mesh-llm server with "no live member is serving
+ // this model" even though discovery found it. See mesh-llm
+ // effective_relay_urls(RelayPolicy::DefaultPublic, &[]).
+ for relay_url in MESH_LLM_DEFAULT_RELAYS {
+ let relay: RelayUrl = relay_url
+ .parse()
+ .unwrap_or_else(|e| panic!("mesh-llm default relay {relay_url:?} must parse: {e}"));
+ assert!(
+ relay_allowed(&relay, &IrohRelayMode::Default),
+ "Default mode must accept mesh-llm default relay {relay_url}"
+ );
+
+ // And end-to-end through the advertised-endpoint validator.
+ let token = endpoint_token_for_test([TransportAddr::Relay(relay)]);
+ assert!(
+ validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).is_ok(),
+ "Default mode must validate an endpoint on mesh-llm default relay {relay_url}"
+ );
+ }
+ }
+
+ #[test]
+ fn endpoint_with_one_good_and_one_junk_candidate_is_sanitized() {
+ // A mesh-llm endpoint can advertise a usable relay alongside an
+ // unusable direct IP. Keep the endpoint reachable, but never pass the
+ // rejected candidate through to iroh's parallel dialer.
+ let good_relay: RelayUrl = MESH_LLM_DEFAULT_RELAYS[0].parse().unwrap();
+ let unsafe_socket = "169.254.169.254:80".parse().unwrap();
+ let token = endpoint_token_for_test([
+ TransportAddr::Relay(good_relay.clone()),
+ TransportAddr::Ip(unsafe_socket),
+ ]);
+ let validated =
+ validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).unwrap();
+ let payload = URL_SAFE_NO_PAD.decode(validated.join_token).unwrap();
+ let sanitized: EndpointAddr = serde_json::from_slice(&payload).unwrap();
+ assert_eq!(
+ sanitized.addrs,
+ [TransportAddr::Relay(good_relay)].into_iter().collect()
+ );
+ }
+
+ #[test]
+ fn endpoint_with_all_junk_candidates_is_rejected() {
+ // Guard: if EVERY candidate is unusable, the endpoint must still fail.
+ let token = endpoint_token_for_test([
+ TransportAddr::Ip("180.181.228.108:0".parse().unwrap()), // port 0
+ TransportAddr::Ip("127.0.0.1:9337".parse().unwrap()), // loopback
+ ]);
+ assert!(
+ validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).is_err(),
+ "endpoint with no usable candidate must be rejected"
+ );
+ }
+
+ #[test]
+ fn default_mode_still_rejects_unknown_relay() {
+ // Guard the fix doesn't over-open: a relay that is neither iroh-prod nor
+ // a mesh-llm default must still be rejected under Default mode.
+ let unknown: RelayUrl = "https://not-a-real-relay.example".parse().unwrap();
+ assert!(!relay_allowed(&unknown, &IrohRelayMode::Default));
+ let token = endpoint_token_for_test([TransportAddr::Relay(unknown)]);
+ assert!(validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).is_err());
+ }
+
#[test]
fn remote_endpoint_rejects_unsafe_direct_targets_and_oversized_tokens() {
for socket in ["127.0.0.1:9337", "169.254.169.254:80", "0.0.0.0:1"] {
@@ -263,10 +421,59 @@ mod tests {
.expect("sign test bootstrap token");
let token = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&signed).unwrap());
assert_eq!(
- validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).unwrap(),
+ validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default)
+ .unwrap()
+ .endpoint_id,
endpoint.id.to_string()
);
+ let good_relay: RelayUrl = MESH_LLM_DEFAULT_RELAYS[0].parse().unwrap();
+ let placeholder_endpoint = EndpointAddr {
+ id: endpoint.id,
+ addrs: [
+ TransportAddr::Relay(good_relay.clone()),
+ TransportAddr::Ip("180.181.228.108:0".parse().unwrap()),
+ ]
+ .into_iter()
+ .collect(),
+ };
+ let placeholder_signed = SignedBootstrapToken::sign(
+ vec![serde_json::to_vec(&placeholder_endpoint).unwrap()],
+ &signed_policy,
+ None,
+ &owner,
+ )
+ .expect("sign placeholder test bootstrap token");
+ let placeholder_token =
+ URL_SAFE_NO_PAD.encode(serde_json::to_vec(&placeholder_signed).unwrap());
+ assert!(
+ validate_advertised_endpoint_with_mode(&placeholder_token, &IrohRelayMode::Default)
+ .is_ok(),
+ "signed stock token with a port-0 placeholder must remain usable"
+ );
+
+ let mixed_endpoint = EndpointAddr {
+ id: endpoint.id,
+ addrs: [
+ TransportAddr::Relay(good_relay),
+ TransportAddr::Ip("169.254.169.254:80".parse().unwrap()),
+ ]
+ .into_iter()
+ .collect(),
+ };
+ let mixed_signed = SignedBootstrapToken::sign(
+ vec![serde_json::to_vec(&mixed_endpoint).unwrap()],
+ &signed_policy,
+ None,
+ &owner,
+ )
+ .expect("sign mixed test bootstrap token");
+ let mixed_token = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&mixed_signed).unwrap());
+ assert!(
+ validate_advertised_endpoint_with_mode(&mixed_token, &IrohRelayMode::Default).is_err(),
+ "signed mixed-candidate tokens must fail closed because they cannot be rewritten"
+ );
+
let mut tampered = signed;
tampered.serialized_addrs[0] = serde_json::to_vec(&EndpointAddr {
id: endpoint.id,
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index 5ecfa89e94..77933cbe78 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz",
- "version": "0.4.10",
+ "version": "0.4.13",
"identifier": "xyz.block.buzz.app",
"build": {
"beforeDevCommand": {
@@ -27,7 +27,7 @@
"dragDropEnabled": false,
"trafficLightPosition": {
"x": 16,
- "y": 24
+ "y": 25
},
"backgroundThrottling": "disabled",
"minWidth": 800,
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 05f8e25819..5b65584e8c 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -4,6 +4,7 @@ import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
+import * as BuzzTheme from "@/app/BuzzThemeSurfaces";
import { AppShellOverlays } from "@/app/AppShellOverlays";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -762,6 +763,7 @@ export function AppShell() {
isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
)}
>
+
{hasCommunityRail ? (
-
+
-
+
+
+
+
+ );
+}
+
+export function ContentSurface({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts
index 4d1a4dda68..978509af09 100644
--- a/desktop/src/features/agents/knownAgentPubkeys.ts
+++ b/desktop/src/features/agents/knownAgentPubkeys.ts
@@ -21,3 +21,23 @@ export function mergeKnownAgentPubkeys(
}
return pubkeys;
}
+
+/**
+ * Channel-scoped variant: the managed ∪ relay baseline plus this channel's
+ * bot members (role `bot` or `isAgent`), so member-only agents are included.
+ */
+export function mergeChannelKnownAgentPubkeys(
+ channelMembers:
+ | readonly { pubkey: string; role: string; isAgent: boolean }[]
+ | undefined,
+ managedAgents: readonly { pubkey: string }[] | undefined,
+ relayAgents: readonly { pubkey: string }[] | undefined,
+): ReadonlySet {
+ const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents));
+ for (const member of channelMembers ?? []) {
+ if (member.role === "bot" || member.isAgent) {
+ pubkeys.add(normalizePubkey(member.pubkey));
+ }
+ }
+ return pubkeys;
+}
diff --git a/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx b/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx
index a5d2335ac7..0b9dcc5aa6 100644
--- a/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx
+++ b/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx
@@ -23,8 +23,11 @@ import {
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
+ getProviderApiKeyEnvVar,
+ requiredCredentialEnvKeys,
} from "@/features/agents/ui/personaDialogPickers";
import { AgentModelField } from "@/features/agents/ui/personaProviderModelFields";
+import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField";
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
import {
BUZZ_AGENT_THINKING_EFFORT,
@@ -100,6 +103,25 @@ export function GlobalAgentConfigFields({
const providerValue = config.provider ?? "";
const providerForDiscovery = isCustomProvider ? "" : providerValue;
+ const credentialProvider = isCustomProvider ? "" : effectiveProvider;
+ const requiredEnvKeys = requiredCredentialEnvKeys(
+ "buzz-agent",
+ credentialProvider,
+ );
+ const apiKeyEnvVar = getProviderApiKeyEnvVar(credentialProvider);
+ const advancedRequiredEnvKeys = requiredEnvKeys.filter(
+ (key) =>
+ key !== apiKeyEnvVar && !bakedEnv.some((entry) => entry.key === key),
+ );
+ const apiKeyValue = apiKeyEnvVar ? (config.env_vars[apiKeyEnvVar] ?? "") : "";
+ const bakedEnvKeys = React.useMemo(
+ () => bakedEnv.map((entry) => entry.key),
+ [bakedEnv],
+ );
+ const apiKeyInherited =
+ apiKeyEnvVar !== null &&
+ apiKeyValue.length === 0 &&
+ bakedEnvKeys.includes(apiKeyEnvVar);
const {
discoveredModelOptions,
@@ -131,21 +153,32 @@ export function GlobalAgentConfigFields({
});
function handleProviderChange(value: string) {
+ const previousApiKey = getProviderApiKeyEnvVar(effectiveProvider);
if (value === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
+ const nextEnvVars = { ...config.env_vars };
+ if (previousApiKey) delete nextEnvVars[previousApiKey];
onIsCustomProviderChange(true);
+ onConfigChange({ ...config, env_vars: nextEnvVars, provider: null });
return;
}
- if (value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "") {
- onIsCustomProviderChange(false);
- onConfigChange({ ...config, provider: null });
- } else {
- onIsCustomProviderChange(false);
- onConfigChange({
- ...config,
- provider: value,
- model: value === "relay-mesh" ? config.model || "auto" : config.model,
- });
+ const nextProvider =
+ value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "" ? null : value;
+ const nextApiKey = getProviderApiKeyEnvVar(
+ nextProvider ?? bakedProvider ?? "",
+ );
+ const nextEnvVars = { ...config.env_vars };
+ if (previousApiKey && previousApiKey !== nextApiKey) {
+ delete nextEnvVars[previousApiKey];
}
+
+ onIsCustomProviderChange(false);
+ onConfigChange({
+ ...config,
+ env_vars: nextEnvVars,
+ provider: nextProvider,
+ model:
+ nextProvider === "relay-mesh" ? config.model || "auto" : config.model,
+ });
}
function handleCustomProviderInput(value: string) {
@@ -168,10 +201,6 @@ export function GlobalAgentConfigFields({
onConfigChange({ ...config, env_vars: merged });
}
- const bakedEnvKeys = React.useMemo(
- () => bakedEnv.map((e) => e.key),
- [bakedEnv],
- );
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
// for new selections; OSS builds show it.
@@ -237,6 +266,29 @@ export function GlobalAgentConfigFields({
+ {apiKeyEnvVar ? (
+
+
+ onConfigChange({
+ ...config,
+ env_vars: { ...config.env_vars, [apiKeyEnvVar]: value },
+ })
+ }
+ value={apiKeyValue}
+ />
+
+ ) : null}
+
{/* Model field */}
k !== BUZZ_AGENT_THINKING_EFFORT,
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 837b15fcf9..77bd557c97 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -1,6 +1,5 @@
import * as React from "react";
-import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
-import { HashSearch } from "@/shared/ui/icons";
+import { Hash, LogIn } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
@@ -45,11 +44,10 @@ import {
type WelcomeComposerBannerState,
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
- getChannelIntroDescription,
- getChannelIntroKind,
isWelcomeSetupSystemMessage,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
+import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
@@ -57,10 +55,7 @@ import { Button } from "@/shared/ui/button";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
-import {
- isWelcomeChannel,
- isWelcomeExperienceChannel as isWelcomeExperience,
-} from "@/features/onboarding/welcome";
+import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
@@ -91,6 +86,8 @@ export const ChannelPane = React.memo(function ChannelPane({
isSinglePanelView = false,
isSending,
isTimelineLoading,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
messages,
threadSummaries,
firstUnreadMessageId = null,
@@ -435,89 +432,20 @@ export const ChannelPane = React.memo(function ChannelPane({
[activeChannel, currentPubkey, profiles],
);
- const channelIntro = React.useMemo(() => {
- if (!activeChannel || activeChannel.channelType === "dm") {
- return null;
- }
-
- const actions = [];
- if (isWelcomeExperience(activeChannel)) {
- if (onBrowseChannels) {
- actions.push({
- icon: ,
- label: "Browse channels",
- onClick: onBrowseChannels,
- testId: "welcome-intro-action-browse-channels",
- });
- }
- if (onCreateChannel) {
- actions.push({
- icon: ,
- label: "Create a channel",
- onClick: onCreateChannel,
- testId: "welcome-intro-action-create-channel",
- });
- }
- if (onAddAgent) {
- actions.push({
- icon: ,
- label: "Create an agent",
- onClick: () =>
- onAddAgent({
- beforeSend: () =>
- messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
- }),
- testId: "welcome-intro-action-create-agent",
- });
- }
- return {
- actions,
- channelKindLabel: isWelcomeChannel(activeChannel)
- ? "private welcome channel"
- : getChannelIntroKind(activeChannel),
- channelName: activeChannel.name,
- description: isWelcomeChannel(activeChannel)
- ? null
- : getChannelIntroDescription(activeChannel),
- icon: ,
- };
- }
-
- if (!activeChannel.archivedAt && activeChannel.isMember) {
- if (onAddAgent) {
- actions.push({
- description: "Add an agent here.",
- icon: ,
- label: "Create agent",
- onClick: onAddAgent,
- testId: "channel-intro-action-create-agent",
- });
- }
-
- if (onOpenMembers) {
- actions.push({
- description: "Invite members.",
- icon: ,
- label: "Add people",
- onClick: onOpenMembers,
- testId: "channel-intro-action-add-people",
- });
- }
- }
-
- return {
- actions,
- channelKindLabel: getChannelIntroKind(activeChannel),
- channelName: activeChannel.name,
- description: getChannelIntroDescription(activeChannel),
- };
- }, [
+ const handleWelcomeAddAgent = React.useCallback(() => {
+ onAddAgent?.({
+ beforeSend: () =>
+ messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
+ });
+ }, [onAddAgent]);
+ const channelIntro = useChannelIntro({
activeChannel,
onAddAgent,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
- ]);
+ onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
+ });
const visibleMessages = React.useMemo(() => {
if (!isWelcomeExperience(activeChannel)) {
return messages;
@@ -689,6 +617,8 @@ export const ChannelPane = React.memo(function ChannelPane({
: "No channel selected"
}
isLoading={isTimelineLoading}
+ entranceMessageId={entranceMessageId}
+ onEntranceMessageComplete={onEntranceMessageComplete}
mainEntries={mainTimelineEntries}
threadSummaries={threadSummaries}
messages={visibleMessages}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index e6a63c8f0c..cead5b9a72 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -53,6 +53,9 @@ export type ChannelPaneProps = {
isSinglePanelView?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
+ /** Newly-created message that should receive the one-shot conversation arrival motion. */
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
messages: TimelineMessage[];
threadSummaries?: ReadonlyMap;
firstUnreadMessageId?: string | null;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 8bfcae0fe6..fc147a63fb 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -25,8 +25,10 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
+import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
+import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance";
import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
import {
mergeMessages,
@@ -247,6 +249,14 @@ export function ChannelScreen({
return extraEvents.reduce(mergeMessages, currentMessages);
}, [activeChannel, findEvents, messagesQuery.data, targetMessageEvents]);
const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
+ const {
+ entranceMessageId: welcomeEntranceMessageId,
+ handleEntranceComplete: handleWelcomeEntranceComplete,
+ } = useWelcomeKickoffEntrance(
+ activeChannel,
+ resolvedMessages,
+ threadReplyEvents,
+ );
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
threadReplyEvents,
@@ -284,21 +294,11 @@ export function ChannelScreen({
});
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
- const knownAgentPubkeys = React.useMemo(() => {
- const pubkeys = new Set();
- for (const member of channelMembers ?? []) {
- if (member.role === "bot" || member.isAgent) {
- pubkeys.add(normalizePubkey(member.pubkey));
- }
- }
- for (const agent of managedAgents) {
- pubkeys.add(normalizePubkey(agent.pubkey));
- }
- for (const agent of relayAgents) {
- pubkeys.add(normalizePubkey(agent.pubkey));
- }
- return pubkeys;
- }, [channelMembers, managedAgents, relayAgents]);
+ const knownAgentPubkeys = React.useMemo(
+ () =>
+ mergeChannelKnownAgentPubkeys(channelMembers, managedAgents, relayAgents),
+ [channelMembers, managedAgents, relayAgents],
+ );
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
@@ -840,6 +840,8 @@ export function ChannelScreen({
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
+ entranceMessageId={welcomeEntranceMessageId}
+ onEntranceMessageComplete={handleWelcomeEntranceComplete}
editTarget={
editTargetMessage
? {
diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
index 79978925ea..0fd62cdbd3 100644
--- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
+++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
@@ -391,8 +391,8 @@ export function WelcomeComposerBanner({ state }: WelcomeComposerBannerProps) {
key="prompt-copy"
variants={welcomeComposerBannerContentVariants}
>
- Try mentioning to chat with an
- agent in this channel.
+ Mention or another teammate
+ whenever you want their help.
)}
diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
new file mode 100644
index 0000000000..c22bbff2c2
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import { Bot, Plus, Sparkles, UserPlus } from "lucide-react";
+
+import {
+ getChannelIntroDescription,
+ getChannelIntroKind,
+} from "@/features/channels/ui/ChannelPane.helpers";
+import {
+ isWelcomeChannel,
+ isWelcomeExperienceChannel,
+} from "@/features/onboarding/welcome";
+import type { Channel } from "@/shared/api/types";
+import { HashSearch } from "@/shared/ui/icons";
+
+type ChannelIntroAction = {
+ description?: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+};
+
+/**
+ * Builds the empty-channel intro block (heading, description, action cards)
+ * for the channel timeline. The Welcome channel gets its onboarding trio
+ * (browse / create channel / create agent); other channels get contextual
+ * member actions.
+ */
+export function useChannelIntro({
+ activeChannel,
+ onAddAgent,
+ onBrowseChannels,
+ onCreateChannel,
+ onOpenMembers,
+ onWelcomeAddAgent,
+}: {
+ activeChannel: Channel | null;
+ onAddAgent?: (options?: { beforeSend?: () => void }) => void;
+ onBrowseChannels?: () => void;
+ onCreateChannel?: () => void;
+ onOpenMembers?: () => void;
+ onWelcomeAddAgent?: () => void;
+}) {
+ return React.useMemo(() => {
+ if (!activeChannel || activeChannel.channelType === "dm") {
+ return null;
+ }
+
+ const actions: ChannelIntroAction[] = [];
+ if (isWelcomeExperienceChannel(activeChannel)) {
+ if (onBrowseChannels) {
+ actions.push({
+ icon: ,
+ label: "Browse channels",
+ onClick: onBrowseChannels,
+ testId: "welcome-intro-action-browse-channels",
+ });
+ }
+
+ if (onCreateChannel) {
+ actions.push({
+ icon: ,
+ label: "Create a channel",
+ onClick: onCreateChannel,
+ testId: "welcome-intro-action-create-channel",
+ });
+ }
+
+ if (onWelcomeAddAgent) {
+ actions.push({
+ icon: ,
+ label: "Create an agent",
+ onClick: onWelcomeAddAgent,
+ testId: "welcome-intro-action-create-agent",
+ });
+ }
+
+ return {
+ actions,
+ channelKindLabel: isWelcomeChannel(activeChannel)
+ ? "private welcome channel"
+ : getChannelIntroKind(activeChannel),
+ channelName: activeChannel.name,
+ description: isWelcomeChannel(activeChannel)
+ ? null
+ : getChannelIntroDescription(activeChannel),
+ icon: ,
+ };
+ }
+
+ if (!activeChannel.archivedAt && activeChannel.isMember) {
+ if (onAddAgent) {
+ actions.push({
+ description: "Add an agent here.",
+ icon: ,
+ label: "Create agent",
+ onClick: onAddAgent,
+ testId: "channel-intro-action-create-agent",
+ });
+ }
+
+ if (onOpenMembers) {
+ actions.push({
+ description: "Invite members.",
+ icon: ,
+ label: "Add people",
+ onClick: onOpenMembers,
+ testId: "channel-intro-action-add-people",
+ });
+ }
+ }
+
+ return {
+ actions,
+ channelKindLabel: getChannelIntroKind(activeChannel),
+ channelName: activeChannel.name,
+ description: getChannelIntroDescription(activeChannel),
+ };
+ }, [
+ activeChannel,
+ onAddAgent,
+ onBrowseChannels,
+ onCreateChannel,
+ onOpenMembers,
+ onWelcomeAddAgent,
+ ]);
+}
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index c911a629e7..d6a6888426 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -13,6 +13,7 @@ import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
+import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
type WelcomeSetupPage = "welcome" | "join" | "invite";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
@@ -94,7 +95,8 @@ export function WelcomeSetup({
data-system-color-scheme={systemColorScheme}
>
-
+
+
{page === "welcome" ? (
-
-
- Welcome to Buzz
-
-
- Choose how you want to get started.
-
-
+
+
+ Join or create a community
+
+
+ Request access to an existing community or create a new one.
+
+
+
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
-
Join default community
-
+
)}
- showPage("join")}
type="button"
>
Join a community
-
-
+ showPage("invite")}
type="button"
- variant="secondary"
>
I have an invite link
-
-
+ void openUrl(CREATE_COMMUNITY_URL)}
type="button"
- variant="ghost"
>
Create a community
-
+
) : page === "join" ? (
@@ -156,9 +152,7 @@ export function WelcomeSetup({
transitionKey={`join-${transitionDirection}`}
>
-
- Join a community
-
+
Join a community
Send your public key to a community owner. Keep Buzz open; once
they add you, their invite link will continue setup here.
@@ -215,9 +209,7 @@ export function WelcomeSetup({
transitionKey={`invite-${transitionDirection}`}
>
-
- I have an invite link
-
+
I have an invite link
Keep this page open, then click the invite link you received.
Buzz will continue automatically. You can also paste it below.
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index a82d381714..eda5fb43a0 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { relayClient } from "@/shared/api/relayClient";
import { applyCommunity, getDefaultRelayUrl } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
+import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { initDraftStore } from "@/features/messages/lib/useDrafts";
@@ -157,6 +158,7 @@ export function useCommunityInit(
undefined,
activeCommunity.token,
activeCommunity.reposDir,
+ getOverrides().agentManagedProfiles === true,
);
} catch (error) {
// A bad `repos_dir` no longer reaches here — `apply_workspace` treats
@@ -184,6 +186,13 @@ export function useCommunityInit(
}
if (!cancelled) {
+ // Refresh relay-derived media state only after the backend has installed
+ // this community's relay override. On cold launch, mediaUrl.ts may have
+ // eagerly cached the default relay origin before applyCommunity ran;
+ // leaving that stale value makes authenticated relay media look external
+ // and bypass the localhost proxy.
+ resetMediaCaches();
+
// Initialise the draft store for this identity so localStorage drafts
// are scoped to the correct pubkey before the app renders.
if (activeCommunity.pubkey) {
diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
index 8bbd0ef00b..011ab408f1 100644
--- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
+++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
@@ -415,16 +415,19 @@ function CatalogPicker({
selected: string;
}) {
const [expanded, setExpanded] = React.useState(false);
- // Collapsed: recommended + the next few viable entries. Expanded: all.
- const visible = expanded ? catalog.entries : catalog.entries.slice(0, 4);
+ // Above the fold: the Buzz-curated picks (models known to work well with
+ // agents on shared compute). Below: everything else, as advanced options.
+ const curated = catalog.entries.filter((e) => e.curated);
+ const advanced = catalog.entries.filter((e) => !e.curated);
+ const visible = expanded ? catalog.entries : curated;
return (
- Suggested for this machine
+ Recommended for this machine
{catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("}
{catalog.vramDisplay} AI memory):
-
+
{visible.map((entry) => {
const isSelected = entry.name === selected;
const tooLarge = entry.fit === "too_large";
@@ -468,15 +471,16 @@ function CatalogPicker({
);
})}
- {catalog.entries.length > visible.length || expanded ? (
+ {advanced.length > 0 ? (
setExpanded((v) => !v)}
type="button"
>
{expanded
- ? "Show fewer"
- : `Show all ${catalog.entries.length} models`}
+ ? "Hide advanced models"
+ : `Advanced: ${advanced.length} more models`}
) : null}
diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts
index 4b3bbd801e..6bbd1e8a12 100644
--- a/desktop/src/features/messages/lib/formatTimelineMessages.ts
+++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts
@@ -580,3 +580,15 @@ export function collectMessageMentionPubkeys(
return [...pubkeys];
}
+
+/** Every pubkey a channel surface needs profiles for: authors (signer +
+ * attributed actor), mentions, and reaction actors, deduplicated. */
+export function collectMessageProfilePubkeys(events: RelayEvent[]) {
+ return [
+ ...new Set([
+ ...collectMessageAuthorPubkeys(events),
+ ...collectMessageMentionPubkeys(events),
+ ...collectReactionActorPubkeys(events),
+ ]),
+ ];
+}
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
index 7738d27f03..de410b5b8f 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
+++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
@@ -403,6 +403,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
+ hasPersistentIntro: false,
isLoading: true,
liveCount: 0,
}),
@@ -411,6 +412,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
+ hasPersistentIntro: false,
isLoading: false,
liveCount: 3,
}),
@@ -418,6 +420,18 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
);
});
+test("timeline-body-surface: first deferred message preserves a persistent channel intro", () => {
+ assert.equal(
+ selectTimelineBodySurface({
+ deferredCount: 0,
+ hasPersistentIntro: true,
+ isLoading: false,
+ liveCount: 1,
+ }),
+ "empty",
+ );
+});
+
test("timeline-body-surface: deferred rows paint the message list", () => {
assert.equal(
selectTimelineBodySurface({
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts
index 2c492fec5a..14d415fb60 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.ts
+++ b/desktop/src/features/messages/lib/timelineSnapshot.ts
@@ -185,10 +185,12 @@ export type TimelineBodySurface = "skeleton" | "empty" | "list";
export function selectTimelineBodySurface({
deferredCount,
+ hasPersistentIntro = false,
isLoading,
liveCount,
}: {
deferredCount: number;
+ hasPersistentIntro?: boolean;
isLoading: boolean;
liveCount: number;
}): TimelineBodySurface {
@@ -198,7 +200,10 @@ export function selectTimelineBodySurface({
const renderState = selectDeferredListRenderState(deferredCount, liveCount);
if (renderState === "pending") {
- return "skeleton";
+ // A channel/DM intro is already meaningful stable content. Preserve it
+ // while React's deferred snapshot catches up to the first live message;
+ // replacing it with a skeleton makes an append look like a page reload.
+ return hasPersistentIntro ? "empty" : "skeleton";
}
return renderState;
}
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
new file mode 100644
index 0000000000..98300acb3e
--- /dev/null
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -0,0 +1,125 @@
+import type * as React from "react";
+import { Hash } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+export type ChannelIntroAction = {
+ description?: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+};
+
+export type ChannelIntro = {
+ actions?: ChannelIntroAction[];
+ channelKindLabel: string;
+ channelName: string;
+ description?: string | null;
+ icon?: React.ReactNode;
+};
+
+/**
+ * The empty-channel intro block: channel icon, heading, kind label, and
+ * action cards. Rendered both as the virtualized timeline's leading row and
+ * as the non-virtualized empty state — one component so the two surfaces
+ * cannot drift and the first message always lands below it without layout
+ * shift.
+ */
+export function ChannelIntroBlock({
+ className,
+ intro,
+}: {
+ className?: string;
+ intro: ChannelIntro;
+}) {
+ return (
+
+
+ {intro.icon ?? }
+
+
+ #{intro.channelName}
+
+
+ This is the beginning of the{" "}
+
+ {intro.channelKindLabel}
+
+ .
+
+ {intro.description ? (
+
+ {intro.description}
+
+ ) : null}
+ {intro.actions?.length ? (
+
+ {intro.actions.map((action) => {
+ const hasDescription = Boolean(action.description);
+
+ return (
+
+
+ {action.icon}
+
+
+
+ {action.label}
+
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 8aef56277f..0a762bff35 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -86,6 +86,8 @@ export const MessageRow = React.memo(
onMarkRead,
onToggleReaction,
onReply,
+ onEntranceComplete,
+ playEntrance = false,
onUnfollowThread,
profiles,
searchQuery,
@@ -132,6 +134,8 @@ export const MessageRow = React.memo(
) => Promise
;
onReply?: (message: TimelineMessage) => void;
onUnfollowThread?: (message: TimelineMessage) => void;
+ onEntranceComplete?: (messageId: string) => void;
+ playEntrance?: boolean;
profiles?: UserProfileLookup;
searchQuery?: string;
showDepthGuides?: boolean;
@@ -143,6 +147,17 @@ export const MessageRow = React.memo(
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState(
null,
);
+ const handleEntranceAnimationEnd = React.useCallback(
+ (event: React.AnimationEvent) => {
+ if (
+ playEntrance &&
+ event.animationName === "motion-enter-conversation"
+ ) {
+ onEntranceComplete?.(message.id);
+ }
+ },
+ [message.id, onEntranceComplete, playEntrance],
+ );
const {
reactions,
canToggle: canToggleReactions,
@@ -749,6 +764,7 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
@@ -836,6 +853,8 @@ export const MessageRow = React.memo(
prev.onCollapseDescendants === next.onCollapseDescendants &&
prev.onCollapseDescendantsHoverChange ===
next.onCollapseDescendantsHoverChange &&
+ prev.onEntranceComplete === next.onEntranceComplete &&
+ prev.playEntrance === next.playEntrance &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewContext === next.videoReviewContext,
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index a8e949ece1..0740cf7da8 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -1,5 +1,4 @@
import * as React from "react";
-import { Hash } from "lucide-react";
import {
isDeferredTimelineSnapshotStale,
@@ -18,6 +17,7 @@ import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
+import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
@@ -51,6 +51,8 @@ type MessageTimelineProps = {
participants: DirectMessageIntroParticipant[];
} | null;
isLoading?: boolean;
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
emptyTitle?: string;
emptyDescription?: string;
currentPubkey?: string;
@@ -111,22 +113,6 @@ type MessageTimelineProps = {
threadUnreadCounts?: ReadonlyMap;
};
-type ChannelIntroAction = {
- description?: string;
- icon: React.ReactNode;
- label: string;
- onClick: () => void;
- testId?: string;
-};
-
-type ChannelIntro = {
- actions?: ChannelIntroAction[];
- channelKindLabel: string;
- channelName: string;
- description?: string | null;
- icon?: React.ReactNode;
-};
-
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render on channel entry stays light instead of blocking on the full
* message list. Must be module-level so its identity never changes. */
@@ -166,6 +152,8 @@ const MessageTimelineBase = React.forwardRef<
mainEntries,
threadSummaries,
isLoading = false,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
currentPubkey,
@@ -282,6 +270,7 @@ const MessageTimelineBase = React.forwardRef<
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
+ hasPersistentIntro: channelIntro !== null || directMessageIntro !== null,
isLoading: isLoading || isDeferredSnapshotStale,
liveCount: messages.length,
});
@@ -427,6 +416,7 @@ const MessageTimelineBase = React.forwardRef<
activeDirectMessageIntro !== null || activeChannelIntro !== null;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";
+ const showChannelIntroOnly = activeChannelIntro !== null && !showMessageList;
const prepareForOwnMessage = React.useCallback(() => {
// The user's own send is the deliberate Zulip exception: release buffered
@@ -581,70 +571,7 @@ const MessageTimelineBase = React.forwardRef<
const virtualizedLeadingContent = React.useMemo(
() =>
activeChannelIntro ? (
-
-
- {activeChannelIntro.icon ?? (
-
- )}
-
-
- #{activeChannelIntro.channelName}
-
-
- This is the beginning of the{" "}
-
- {activeChannelIntro.channelKindLabel}
-
- .
-
- {activeChannelIntro.description ? (
-
- {activeChannelIntro.description}
-
- ) : null}
- {activeChannelIntro.actions?.length ? (
-
- {activeChannelIntro.actions.map((action) => (
-
-
- {action.icon}
-
-
-
- {action.label}
-
- {action.description ? (
-
- {action.description}
-
- ) : null}
-
-
- ))}
-
- ) : null}
-
+
) : activeDirectMessageIntro ? (
-
+ {showChannelIntroOnly ? null : (
+
+ )}
- {/* Fixed-height slot: an always-mounted height keeps the virtual
- spacer's offset stable across the load-older fetch toggle, so
- `scrollMargin` doesn't shift mid-fetch and yank the restore. The
- visible fetch spinner lives in the absolute overlay above, which
- does not occupy inline flow. */}
-
+ {/* Fixed-height history slot keeps the virtual spacer's offset
+ stable across load-older fetches. The intro-only state has no
+ history to anchor, so omitting it matches the virtualized
+ leading row's top geometry when the first message arrives. */}
+ {showChannelIntroOnly ? null : (
+
+ )}
-
- {activeChannelIntro.icon ?? (
-
- )}
-
-
- #{activeChannelIntro.channelName}
-
-
- This is the beginning of the{" "}
-
- {activeChannelIntro.channelKindLabel}
-
- .
-
- {activeChannelIntro.description ? (
-
- {activeChannelIntro.description}
-
- ) : null}
- {activeChannelIntro.actions?.length ? (
-
- {activeChannelIntro.actions.map((action) => {
- const hasDescription = Boolean(action.description);
-
- return (
-
-
- {action.icon}
-
-
-
- {action.label}
-
- {action.description ? (
-
- {action.description}
-
- ) : null}
-
-
- );
- })}
-
- ) : null}
-
+ /* Top-anchored like the virtualized leading row, so the
+ first message arrives below with zero layout shift. */
+
) : null}
{showGenericEmpty ? (
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 1f57263fb8..a05e936f46 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -63,6 +63,8 @@ type TimelineMessageListProps = {
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
messageFooters?: Record
;
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
* to deriving them here when omitted (e.g. the deferred-render pass). */
@@ -130,6 +132,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
huddleMemberPubkeysPending = false,
isFollowingThreadById,
isMessageUnreadById,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
messageFooters,
mainEntries,
threadSummaries,
@@ -262,6 +266,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
isFollowedByContinuation={item.isFollowedByContinuation}
isFollowingThreadById={isFollowingThreadById}
isUnread={isMessageUnreadById?.(item.entry.message.id)}
+ playEntrance={item.entry.message.id === entranceMessageId}
+ onEntranceComplete={onEntranceMessageComplete}
onDelete={onDelete}
onEdit={onEdit}
onMarkRead={onMarkRead}
@@ -290,6 +296,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
huddleMemberPubkeysPending,
isFollowingThreadById,
isMessageUnreadById,
+ entranceMessageId,
+ onEntranceMessageComplete,
messageFooters,
onDelete,
onEdit,
@@ -831,6 +839,8 @@ type MessageRowItemProps = Pick<
isContinuation?: boolean;
isFollowedByContinuation?: boolean;
isUnread?: boolean;
+ playEntrance?: boolean;
+ onEntranceComplete?: (messageId: string) => void;
videoReviewContext: ReturnType;
};
@@ -847,6 +857,8 @@ function MessageRowItem({
isFollowedByContinuation = false,
isFollowingThreadById,
isUnread,
+ playEntrance = false,
+ onEntranceComplete,
onDelete,
onEdit,
onMarkUnread,
@@ -893,6 +905,8 @@ function MessageRowItem({
}
isUnread={isUnread}
isContinuation={isContinuation}
+ playEntrance={playEntrance}
+ onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
@@ -943,6 +957,8 @@ function MessageRowItem({
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isContinuation={isContinuation}
isUnread={isUnread}
+ playEntrance={playEntrance}
+ onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
diff --git a/desktop/src/features/onboarding/devFreshOnboarding.ts b/desktop/src/features/onboarding/devFreshOnboarding.ts
new file mode 100644
index 0000000000..3a129d83ff
--- /dev/null
+++ b/desktop/src/features/onboarding/devFreshOnboarding.ts
@@ -0,0 +1,16 @@
+const FORCE_FRESH_VALUE = "true";
+
+/**
+ * Development-only switch for replaying first-run onboarding with an existing
+ * identity. Vite removes the DEV branch from production builds.
+ */
+export const forceFreshOnboarding =
+ import.meta.env?.DEV === true &&
+ import.meta.env.VITE_BUZZ_FORCE_FRESH_ONBOARDING === FORCE_FRESH_VALUE;
+
+const devBootId = forceFreshOnboarding ? crypto.randomUUID() : null;
+
+/** Give each forced-fresh webview boot its own relay marker namespace. */
+export function welcomeKickoffMarker(baseMarker: string) {
+ return devBootId ? `${baseMarker}.dev-${devBootId}` : baseMarker;
+}
diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts
index b8a14fb07d..7495af48eb 100644
--- a/desktop/src/features/onboarding/hooks.ts
+++ b/desktop/src/features/onboarding/hooks.ts
@@ -9,21 +9,62 @@ import {
import { channelsQueryKey } from "@/features/channels/hooks";
import {
ensureStarterChannels,
+ ensureWelcomeChannel,
hasEnsuredWelcomeChannel,
markWelcomeChannelEnsured,
notifyWelcomeChannelReady,
rememberPendingWelcomeChannel,
} from "@/features/onboarding/welcome";
-import { ensureWelcomeGuideIntro } from "@/features/onboarding/welcomeGuide";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
+import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas";
+import { ensureWelcomeTeam } from "@/features/onboarding/welcomeGuide";
import { useProfileQuery } from "@/features/profile/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
+import type { Channel } from "@/shared/api/types";
import {
+ createChannel,
+ deleteChannel,
ensureStarterChannels as ensureStarterChannelsCommand,
+ getChannelMembers,
getChannels,
+ updateChannel,
} from "@/shared/api/tauri";
-export type ChannelInitResult = { ok: true } | { ok: false; reason: string };
+const STARTER_CHANNEL_SETUP_TOAST_ID = "starter-channel-setup-error";
+
+export type ChannelInitResult =
+ | { ok: true; focusChannelId?: string }
+ | { ok: false; reason: string; focusChannelId?: string };
+
+const welcomeSeedPromises = new Map>();
+
+function seedWelcomeExperience(
+ queryClient: ReturnType,
+ channelId: string,
+ pubkey: string | null,
+ communityScope: string | null,
+) {
+ const key = `${communityScope ?? ""}:${channelId}`;
+ const current = welcomeSeedPromises.get(key);
+ if (current) return current;
+
+ const promise = (async () => {
+ try {
+ await ensureWelcomeTeam(channelId, communityScope);
+ await ensureWelcomeCanvas(channelId);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
+ ]);
+ markWelcomeChannelEnsured(pubkey, communityScope);
+ } catch (error) {
+ console.warn("Failed to seed the private Welcome experience.", error);
+ }
+ })().finally(() => welcomeSeedPromises.delete(key));
+ welcomeSeedPromises.set(key, promise);
+ return promise;
+}
export async function initializeStarterChannels(
queryClient: ReturnType,
@@ -38,33 +79,83 @@ export async function initializeStarterChannels(
},
): Promise {
try {
- const starterChannels = await ensureStarterChannels({
- ensureStarterChannels: ensureStarterChannelsCommand,
- getChannels,
- });
-
- markWelcomeChannelEnsured(pubkey, communityScope);
- if (focus) {
- rememberPendingWelcomeChannel(starterChannels.welcomeChannel.id);
- }
- queryClient.setQueryData(channelsQueryKey, starterChannels.channels);
+ let starterChannels: Awaited<
+ ReturnType
+ > | null = null;
+ let starterChannelsError: unknown = null;
try {
- await ensureWelcomeGuideIntro(
- starterChannels.welcomeChannel.id,
- communityScope,
- );
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
- queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
- ]);
+ starterChannels = await ensureStarterChannels({
+ ensureStarterChannels: ensureStarterChannelsCommand,
+ getChannels,
+ });
} catch (error) {
- console.warn("Failed to initialize Welcome guide.", error);
+ starterChannelsError = error;
+ console.warn("Failed to initialize public starter channels.", error);
}
+
+ const welcomeChannel = await ensureWelcomeChannel(
+ {
+ createChannel,
+ deleteChannel,
+ getChannelMembers,
+ getChannels,
+ updateChannel,
+ },
+ {
+ replaceExisting: forceFreshOnboarding,
+ },
+ );
+
+ const starterChannelList = starterChannels?.channels ?? [];
+ queryClient.setQueryData(channelsQueryKey, (channels = []) => {
+ const ensuredIds = new Set(
+ starterChannelList.map((channel) => channel.id),
+ );
+ ensuredIds.add(welcomeChannel.id);
+ return [
+ ...starterChannelList,
+ ...(starterChannelList.some(
+ (channel) => channel.id === welcomeChannel.id,
+ )
+ ? []
+ : [welcomeChannel]),
+ ...channels.filter((channel) => !ensuredIds.has(channel.id)),
+ ];
+ });
+ void seedWelcomeExperience(
+ queryClient,
+ welcomeChannel.id,
+ pubkey,
+ communityScope,
+ );
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (focus) {
- notifyWelcomeChannelReady(starterChannels.welcomeChannel.id);
+ // Refreshing can briefly replace the optimistic cache with an older relay
+ // snapshot. Reinsert the just-ensured channels before announcing focus so
+ // the route can consume the pending private Welcome channel immediately.
+ queryClient.setQueryData(channelsQueryKey, (channels = []) => {
+ const byId = new Map(
+ [...channels, ...starterChannelList, welcomeChannel].map(
+ (channel) => [channel.id, channel],
+ ),
+ );
+ return [...byId.values()];
+ });
+ rememberPendingWelcomeChannel(welcomeChannel.id);
+ notifyWelcomeChannelReady(welcomeChannel.id);
}
- return { ok: true };
+ const focusChannelId = focus ? welcomeChannel.id : undefined;
+ if (starterChannelsError) {
+ return {
+ ok: false,
+ focusChannelId,
+ reason:
+ starterChannelsError instanceof Error
+ ? starterChannelsError.message
+ : "Failed to set up starter channels",
+ };
+ }
+ return { ok: true, focusChannelId };
} catch (error) {
console.warn("Failed to initialize starter channels.", error);
return {
@@ -116,6 +207,9 @@ function onboardingCompletionStorageKey(pubkey: string) {
}
function readOnboardingCompletion(pubkey: string | null) {
+ if (forceFreshOnboarding) {
+ return false;
+ }
if (typeof window === "undefined" || !pubkey) {
return false;
}
@@ -236,6 +330,7 @@ export function useFirstRunOnboardingGate({
// main checkout. Skip unconditionally without waiting for the relay
// profile query. Guarded by !hasCompletedCurrentPubkey so it fires once.
if (
+ !forceFreshOnboarding &&
isSharedIdentity &&
currentPubkey &&
identityStatus === "success" &&
@@ -292,7 +387,9 @@ export function useFirstRunOnboardingGate({
// still exists, so onboarding is skipped. A missing event (new user, or no
// kind:0 on the relay) always shows onboarding regardless of display_name.
const hasExistingProfile =
- profileStatus === "success" && profileHasEvent === true;
+ !forceFreshOnboarding &&
+ profileStatus === "success" &&
+ profileHasEvent === true;
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => {
@@ -465,15 +562,14 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
focus,
pubkey: currentPubkey,
communityScope: starterChannelsCommunityScope,
+ }).finally(() => {
+ starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
+ starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
});
starterChannelsInitPromisesRef.current.set(
starterChannelsInitKey,
promise,
);
- void promise.finally(() => {
- starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
- starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
- });
return promise;
},
[currentPubkey, queryClient, starterChannelsCommunityScope],
@@ -501,13 +597,22 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const showStarterRetryToast = React.useCallback(
(reason: string) => {
toast.error("Couldn't set up starter channels", {
+ id: STARTER_CHANNEL_SETUP_TOAST_ID,
action: {
label: "Retry",
- onClick: () => {
+ onClick: (event) => {
+ event.preventDefault();
void requestStarterChannels(true).then((result) => {
if (!result.ok) {
- showStarterRetryToast(result.reason);
+ window.setTimeout(
+ // Sonner dismisses an action toast as its click resolves, so
+ // recreate a failed retry after that dismissal completes.
+ () => showStarterRetryToast(result.reason),
+ 0,
+ );
+ return;
}
+ toast.dismiss(STARTER_CHANNEL_SETUP_TOAST_ID);
});
},
},
@@ -519,17 +624,19 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const completeAndShowWelcome = React.useCallback(() => {
setIsCompletingStarterSetup(true);
- gateComplete();
- void requestStarterChannels(true)
- .then((starterResult) => {
- if (!starterResult.ok) {
- showStarterRetryToast(starterResult.reason);
- }
- return refreshChannelsCache(queryClient);
- })
- .finally(() => {
- setIsCompletingStarterSetup(false);
- });
+ void requestStarterChannels(true).then(async (starterResult) => {
+ await refreshChannelsCache(queryClient);
+ gateComplete();
+ setIsCompletingStarterSetup(false);
+ if (starterResult.focusChannelId) {
+ window.location.hash = `/channels/${encodeURIComponent(
+ starterResult.focusChannelId,
+ )}`;
+ }
+ if (!starterResult.ok) {
+ showStarterRetryToast(starterResult.reason);
+ }
+ });
}, [
gateComplete,
queryClient,
diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx
index 20fa8b4586..7333bea5c9 100644
--- a/desktop/src/features/onboarding/ui/AvatarStep.tsx
+++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx
@@ -8,7 +8,8 @@ import { Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
-import { StepProgress } from "@/shared/ui/step-progress";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import {
@@ -26,8 +27,6 @@ type AvatarStepProps = {
submit: ProfileStepActions["submit"];
updateAvatarUrl: ProfileStepActions["updateAvatarUrl"];
};
- /** Step number for the compact progress bar (mobile layout). */
- currentStep: number;
direction: OnboardingTransitionDirection;
/** When true, a ghost "Skip for now" button is always visible (not just on error). */
showAlwaysSkip?: boolean;
@@ -35,8 +34,6 @@ type AvatarStepProps = {
ProfileStepState,
"avatar" | "isSaving" | "isUploadingAvatar" | "name" | "saveRecovery"
>;
- /** Total steps for the compact progress bar (mobile layout). */
- totalSteps?: number;
};
function ErrorBanner({ message }: { message: string | null }) {
@@ -125,7 +122,6 @@ function AvatarPreview({
function AvatarStepActions({
canSubmit,
- currentStep,
hidden,
isSaving,
isUploadingAvatar,
@@ -135,10 +131,8 @@ function AvatarStepActions({
onSubmit,
saveRecovery,
showAlwaysSkip,
- totalSteps,
}: {
canSubmit: boolean;
- currentStep: number;
hidden: boolean;
isSaving: boolean;
isUploadingAvatar: boolean;
@@ -148,118 +142,108 @@ function AvatarStepActions({
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
showAlwaysSkip: boolean;
- totalSteps?: number;
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
return (
-
- {hidden ? null : (
-
-
+
+ {hidden ? null : (
+
- {isSaving || isUploadingAvatar ? (
-
- ) : (
- "Next"
- )}
-
-
- {saveRecovery.canSkipForNow ? (
- // Error-recovery path: exits onboarding entirely when there is no
- // saved display name to fall back on.
+ {isSaving || isUploadingAvatar ? (
+
+ ) : (
+ "Next"
+ )}
+
+
+ {saveRecovery.canSkipForNow ? (
+ // Error-recovery path: exits onboarding entirely when there is no
+ // saved display name to fall back on.
+
+ Skip for now
+
+ ) : showAlwaysSkip && !saveRecovery.errorMessage ? (
+ // Normal path: advances to the theme step without saving an avatar.
+
+ Skip for now
+
+ ) : null}
+
+ {saveRecovery.canAdvanceWithoutSaving ? (
+
+ Continue without saving
+
+ ) : null}
+
+
- Skip for now
+ Back
- ) : showAlwaysSkip && !saveRecovery.errorMessage ? (
- // Normal path: advances to the theme step without saving an avatar.
-
- Skip for now
-
- ) : null}
-
- {saveRecovery.canAdvanceWithoutSaving ? (
-
- Continue without saving
-
- ) : null}
-
-
- Back
-
-
-
-
- )}
-
+
+ )}
+
+
);
}
export function AvatarStep({
actions,
- currentStep,
direction,
showAlwaysSkip = false,
state,
- totalSteps,
}: AvatarStepProps) {
const {
advanceWithoutSaving,
@@ -328,7 +312,11 @@ export function AvatarStep({
return (
-
+
Next, add a display image
-
+
Choose an image or emoji as your avatar
@@ -411,7 +399,6 @@ export function AvatarStep({
diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx
index 9fca6a8c84..d88e151379 100644
--- a/desktop/src/features/onboarding/ui/BackupStep.tsx
+++ b/desktop/src/features/onboarding/ui/BackupStep.tsx
@@ -4,6 +4,8 @@ import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -79,16 +81,16 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
transitionKey={`backup-${direction}`}
>
-
+
Your unique identity has been created
-
+
This key is stored in your system keychain, but save it some place
safe in case you ever need to restore your account.
-
+
{isLoading ? (
@@ -120,7 +122,8 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : nsec ? (
-
+ // Translucent white card frames the key with equal padding.
+
) : (
@@ -130,7 +133,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
)}
{nsec ? (
-
+
Never share your private key. Anyone can impersonate you and
@@ -140,9 +143,9 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : null}
-
+
Back
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
index a48926902e..a7b21334e7 100644
--- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { Bot, Users } from "lucide-react";
+import { Plus, Users, X } from "lucide-react";
import {
markCommunityOnboardingComplete,
@@ -8,15 +8,73 @@ import {
} from "@/features/onboarding/communityOnboarding";
import { initializeStarterChannels } from "@/features/onboarding/hooks";
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
-import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+ parseEmojiAvatarDataUrl,
+ ProfileAvatarEditor,
+} from "@/features/profile/ui/ProfileAvatarEditor";
import { updateProfile } from "@/shared/api/tauriProfiles";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { listPersonas } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
+import {
+ ONBOARDING_PRIMARY_CTA_CLASS,
+ OnboardingChrome,
+} from "./OnboardingChrome";
+import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
+
+const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
+ "--buzz-emoji-picker-rgb-background":
+ "var(--buzz-onboarding-emoji-picker-background)",
+ "--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
+ "--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
+} as React.CSSProperties;
+
+function AvatarCircle({
+ avatarUrl,
+ onClick,
+ previewName,
+}: {
+ avatarUrl: string;
+ onClick: () => void;
+ previewName: string;
+}) {
+ const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
+ const hasAvatar = avatarUrl.trim().length > 0;
+
+ return (
+
+ {emojiAvatar ? (
+
+ {emojiAvatar.emoji}
+
+ ) : hasAvatar ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
export function CommunityOnboardingFlow({
onConnect,
@@ -28,6 +86,7 @@ export function CommunityOnboardingFlow({
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
+ const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [starterPersonas, setStarterPersonas] = React.useState
(
[],
);
@@ -102,130 +161,184 @@ export function CommunityOnboardingFlow({
}
};
+ const isProfileStage = transaction.stage === "profile";
+ const isTeamStage =
+ transaction.stage === "team-intro" || transaction.stage === "finalizing";
+
return (
-
- {transaction.stage === "claiming" ||
- transaction.stage === "connecting" ? (
- <>
-
-
- Joining {transaction.communityName}
-
-
- {transaction.error ??
- (transaction.stage === "claiming"
- ? "Accepting your invite…"
- : "Connecting securely…")}
-
-
- {transaction.error ? (
- Retry
- ) : null}
-
- Cancel
-
-
- >
- ) : transaction.stage === "profile" ? (
- <>
-
- How should you appear here?
-
-
- Your name and avatar are specific to {transaction.communityName}.
-
-
-
setDisplayName(event.target.value)}
- placeholder="Your name"
- value={displayName}
- />
-
setAvatarUrl("")}
- onUploadingChange={setIsUploadingAvatar}
- onUrlChange={setAvatarUrl}
- previewName={displayName.trim() || "Your profile"}
- showClear={avatarUrl.length > 0}
- testIdPrefix="community-avatar"
- />
- {transaction.error ? (
- {transaction.error}
- ) : null}
- void saveProfile()}
- >
- Continue
-
-
- >
- ) : (
- <>
-
-
- Meet your starter team
-
-
- Fizz helps you build, Honey helps you communicate, and Bumble
- helps you research. They’ll be ready when you need them.
-
- {starterPersonas.length > 0 ? (
-
- {starterPersonas.map((persona) => (
-
-
-
- {persona.displayName}
-
-
- ))}
-
- ) : null}
- {transaction.error ? (
-
- {transaction.error}
+ {isProfileStage || isTeamStage ? (
+
+ ) : null}
+
+
+ {transaction.stage === "claiming" ||
+ transaction.stage === "connecting" ? (
+ <>
+
+
+ Joining {transaction.communityName}
+
+
+ {transaction.error ??
+ (transaction.stage === "claiming"
+ ? "Accepting your invite…"
+ : "Connecting securely…")}
- ) : null}
-
-
void finalize()}
- >
- {transaction.stage === "finalizing"
- ? "Preparing Welcome…"
- : `Enter ${transaction.communityName}`}
-
- {transaction.error ? (
+
+ {transaction.error ? (
+
+ Retry
+
+ ) : null}
void finish()}
+ className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
+ onClick={clear}
variant="ghost"
>
- Skip for now
+ Cancel
+
+ >
+ ) : isProfileStage ? (
+ isAvatarEditorOpen ? (
+
+
setIsAvatarEditorOpen(false)}
+ size="icon"
+ type="button"
+ >
+
+
+
setIsAvatarEditorOpen(false)}
+ onUploadingChange={setIsUploadingAvatar}
+ onUrlChange={setAvatarUrl}
+ previewName={displayName.trim() || "Your profile"}
+ testIdPrefix="community-avatar"
+ />
+
+ ) : (
+ <>
+
Build your profile
+
+ Add a name and avatar. They’ll show up on your messages,
+ reactions, and agent handoffs.
+
+
+
setIsAvatarEditorOpen(true)}
+ previewName={displayName.trim() || "Your profile"}
+ />
+
+
+
+ Your name
+
+ setDisplayName(event.target.value)}
+ placeholder="First and last name"
+ value={displayName}
+ />
+
+ {transaction.error ? (
+
+ {transaction.error}
+
+ ) : null}
+
+ void saveProfile()}
+ >
+ Continue
+
+
+ >
+ )
+ ) : (
+ <>
+
Meet your starter team
+
+ Buzz lets you bring multiple agents into the same workspace.
+ This team will help you get started using Buzz.
+
+ {starterPersonas.length > 0 ? (
+
+ {starterPersonas.map((persona) => (
+
+
+
+ {persona.displayName}
+
+
+ ))}
+
) : null}
-
- >
- )}
-
+ {transaction.error ? (
+
+ {transaction.error}
+
+ ) : null}
+
+ void finalize()}
+ >
+ {transaction.stage === "finalizing"
+ ? "Preparing Welcome…"
+ : `Enter ${transaction.communityName}`}
+
+ {transaction.error ? (
+ void finish()}
+ variant="ghost"
+ >
+ Skip for now
+
+ ) : null}
+
+ >
+ )}
+
+
);
}
diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
new file mode 100644
index 0000000000..31a0f357ab
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
@@ -0,0 +1,181 @@
+import * as React from "react";
+
+import { useAcpRuntimesQuery } from "@/features/agents/hooks";
+import {
+ GlobalAgentConfigFields,
+ EMPTY_GLOBAL_CONFIG,
+} from "@/features/agents/ui/GlobalAgentConfigFields";
+import { createSaveCoalescer } from "./saveCoalescer";
+import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
+import {
+ getGlobalAgentConfig,
+ setGlobalAgentConfig,
+} from "@/shared/api/tauriGlobalAgentConfig";
+import type { GlobalAgentConfig } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
+import type { DefaultConfigStepActions } from "./types";
+import { resolveAgentReadiness } from "./agentReadiness";
+
+type DefaultConfigStepProps = {
+ actions: DefaultConfigStepActions;
+ direction: OnboardingTransitionDirection;
+};
+
+function AgentDefaultsSection() {
+ const runtimesQuery = useAcpRuntimesQuery();
+ const [config, setConfig] =
+ React.useState(EMPTY_GLOBAL_CONFIG);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [isCustomProvider, setIsCustomProvider] = React.useState(false);
+ const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
+ const [bakedEnv, setBakedEnv] = React.useState([]);
+ const coalescerRef = React.useRef<{
+ enqueue: (value: GlobalAgentConfig) => void;
+ cancel: () => void;
+ } | null>(null);
+
+ React.useEffect(() => {
+ let unmounted = false;
+
+ getGlobalAgentConfig()
+ .then((loaded) => {
+ if (!unmounted) {
+ setConfig(loaded);
+ setIsLoading(false);
+ }
+ })
+ .catch(() => {
+ if (!unmounted) setIsLoading(false);
+ });
+ getBakedBuildEnv()
+ .then((env) => {
+ if (!unmounted) setBakedEnv(env);
+ })
+ .catch(() => undefined);
+
+ // The coalescer serializes autosaves and drains any edit that arrived
+ // while a previous save was in flight. Cancel on unmount so a slow
+ // in-flight request never calls setState on an unmounted component.
+ const coalescer = createSaveCoalescer(
+ // set_global_agent_config returns a save result (config + restart
+ // counts); the coalescer round-trips the persisted config only.
+ async (next) => (await setGlobalAgentConfig(next)).config,
+ () => undefined, // saving state not surfaced in this autosave UX
+ (saved) => {
+ if (!unmounted) setConfig(saved);
+ },
+ );
+ coalescerRef.current = coalescer;
+
+ return () => {
+ unmounted = true;
+ coalescer.cancel();
+ };
+ }, []);
+
+ const buzzAgentRuntime = React.useMemo(
+ () => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
+ [runtimesQuery.data],
+ );
+
+ const readiness = resolveAgentReadiness(runtimesQuery.data ?? [], config);
+
+ return (
+
+ {isLoading ? (
+
+
+ Loading…
+
+ ) : (
+
+ {
+ // Always apply optimistically so the UI never reverts mid-save,
+ // then enqueue the persist — the coalescer serialises multiple
+ // rapid edits into a single trailing request.
+ setConfig(next);
+ coalescerRef.current?.enqueue(next);
+ }}
+ onCustomModelEditingChange={setIsCustomModelEditing}
+ onIsCustomProviderChange={setIsCustomProvider}
+ />
+
+ )}
+
+ {!readiness.ready ? (
+
+ You can finish now and configure agents later in Settings.
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Machine onboarding page 4 — default model configuration. Presents the
+ * global agent defaults (provider, model, effort, env vars) centered under
+ * the mock's "Configure your default model settings" heading.
+ */
+export function DefaultConfigStep({
+ actions,
+ direction,
+}: DefaultConfigStepProps) {
+ return (
+
+
+
+ Configure your default model settings
+
+
+ This will be set as your default model configuration across Buzz. You
+ can always change this in your Settings.
+
+
+
+
+
+
+
+ Next
+
+
+
+ Back
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 35243f8496..52b1cb291c 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -9,13 +9,18 @@ import {
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
+import { DefaultConfigStep } from "./DefaultConfigStep";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
+import {
+ ONBOARDING_LANDING_CTA_CLASS,
+ OnboardingChrome,
+} from "./OnboardingChrome";
+import { OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
-import { OnboardingStepDots } from "./OnboardingStepDots";
import { SetupStep } from "./SetupStep";
-type MachinePage = "identity" | "key-import" | "backup" | "setup";
+type MachinePage = "identity" | "key-import" | "backup" | "setup" | "config";
export function MachineOnboardingFlow({
complete,
@@ -89,96 +94,115 @@ export function MachineOnboardingFlow({
return (
{page === "identity" ?
: null}
{page !== "identity" ? (
-
+
) : null}
-
- {page === "identity" ? (
-
-
+
+ {page === "identity" ? (
+
+
+
+ Your people, your agents, your projects —
+ all in one place.
+
+ {error ? (
+ {error}
+ ) : null}
+
+ void loadFreshIdentity()}
+ type="button"
+ >
+ {isPending ? "Saving identity…" : "Get started"}
+
+ setPage("key-import")}
+ type="button"
+ variant="ghost"
+ >
+ Enter a key
+
+
+
+ ) : page === "key-import" ? (
+
+
+ {identityLost ? "Re-import your key" : "Enter your private key"}
+
+
+ {identityLost
+ ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
+ : "If you already have a Nostr account, enter your private key below to get started."}
+
+ void replaceLostIdentity()
+ : () => setPage("identity")
+ }
+ onImport={importExistingIdentity}
+ variant="spotlight"
+ />
+
+ ) : page === "backup" ? (
+
setPage("identity")}
+ onNext={() => setPage("setup")}
/>
-
- Your people, your agents, your projects — all in one place.
-
- {error ? (
- {error}
- ) : null}
-
- void loadFreshIdentity()}
- type="button"
- >
- {isPending ? "Saving identity…" : "Get started"}
-
- setPage("key-import")}
- type="button"
- variant="ghost"
- >
- Enter a key
-
-
-
- ) : page === "key-import" ? (
-
-
- {identityLost ? "Re-import your key" : "Enter your private key"}
-
-
- {identityLost
- ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
- : "If you already have a Nostr account, enter your private key below to get started."}
-
- void replaceLostIdentity()
- : () => setPage("identity")
- }
- onImport={importExistingIdentity}
- variant="spotlight"
+ ) : page === "setup" ? (
+
+ setPage(identityWasImported ? "key-import" : "backup"),
+ next: () => setPage("config"),
+ }}
+ direction="forward"
/>
-
- ) : page === "backup" ? (
- setPage("identity")}
- onNext={() => setPage("setup")}
- />
- ) : (
-
- setPage(identityWasImported ? "key-import" : "backup"),
- complete: () => complete(selectedPubkey ?? undefined),
- }}
- direction="forward"
- />
- )}
-
+ ) : (
+ setPage("setup"),
+ complete: () => complete(selectedPubkey ?? undefined),
+ }}
+ direction="forward"
+ />
+ )}
+
+
);
}
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index f8cd81f820..b4007e92fd 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -6,6 +6,8 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
@@ -87,6 +89,14 @@ export function NostrKeyImportForm({
}, []);
const handleSubmit = React.useCallback(async () => {
+ // Guard here, not just on the submit button: the button now lives in the
+ // portaled footer as type="button", so the single-field form still submits
+ // on Enter. Without this, pressing Enter during an in-flight import fires a
+ // second concurrent onImport (double keyring write).
+ if (isInteractionDisabled) {
+ return;
+ }
+
if (!previewNpub) {
setImportError(
"That doesn't look like a valid nsec. Paste an nsec1 key.",
@@ -106,7 +116,7 @@ export function NostrKeyImportForm({
} finally {
setIsImporting(false);
}
- }, [onImport, previewNpub, trimmedInput]);
+ }, [isInteractionDisabled, onImport, previewNpub, trimmedInput]);
return (
);
}
diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
index d7aa4826d8..e6f06f158a 100644
--- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
+++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
@@ -44,31 +44,55 @@ export function NsecMaskedDisplay({
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
}
+ const isBare = variant === "bare";
+ // Mask every character (no plaintext prefix leak), matching the real key's
+ // length so toggling reveal never reflows the monospace text (no layout shift).
+ // Bullets are joined with a zero-width space: WebKit (WKWebView) will not
+ // line-break a run of U+2022 even with break-all/overflow-wrap, so without an
+ // explicit break opportunity the masked key overflows its container. The mask
+ // is decorative and non-selectable, and copy uses the raw nsec, so the
+ // injected ZWSP is never surfaced.
+ const maskedNsec = React.useMemo(
+ () => Array.from(nsec, () => "•").join("\u200b"),
+ [nsec],
+ );
+ const iconSize = isBare ? "h-6 w-6" : "h-4 w-4";
+
return (
-
-
- {isRevealed ? nsec : nsec.slice(0, 6) + "•".repeat(52)}
-
-
+
+ {/* Wrapping element is a block inside the flex item, not the flex item
+ itself: WebKit (WKWebView) does not wrap a long unbroken string when
+ the text node is the flex child directly, even with break-all /
+ overflow-wrap. A plain block wraps reliably in every engine. */}
+
+
+ {isRevealed ? nsec : maskedNsec}
+
+
+
{isRevealed ? (
-
+
) : (
-
+
)}
void handleCopy()}
size="icon"
type="button"
variant="ghost"
>
{isCopied ? (
-
+
) : (
-
+
)}
diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
new file mode 100644
index 0000000000..75badd74a8
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
@@ -0,0 +1,65 @@
+import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
+
+/**
+ * Positions in the first-launch flow: landing, identity/key, harness setup,
+ * default config, community choice, community profile, meet the team. Used as
+ * the default pagination length when a flow doesn't pass an explicit total.
+ */
+export const TOTAL_ONBOARDING_PAGES = 7;
+
+/** Shared pill shape (38px tall) for every onboarding primary CTA. */
+const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
+
+/**
+ * Primary-CTA styling for the in-step onboarding pages: the shared pill with a
+ * light-blue label (`--buzz-onboarding-cta-label`, available on any
+ * `.buzz-onboarding-neutral-theme` subtree).
+ */
+export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
+
+/**
+ * Primary-CTA styling for the landing screen only: the shared pill with the
+ * chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
+ * for the CTAs inside the steps.
+ */
+export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
+
+/**
+ * Shared onboarding chrome shown on every page after the landing screen: a
+ * static Buzz mark pinned to the top-left, and a centered pagination track that
+ * sits above the page title. The active page reads as a longer bar; inactive
+ * pages are dots.
+ */
+export function OnboardingChrome({
+ current,
+ total = TOTAL_ONBOARDING_PAGES,
+}: {
+ current: number;
+ total?: number;
+}) {
+ return (
+
+
+
+
+
+ {Array.from({ length: total }, (_, i) => i + 1).map((position) => (
+
+ ))}
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index e507f4b614..1928ee3835 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -13,10 +13,12 @@ import {
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
-import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
+import { OnboardingChrome } from "./OnboardingChrome";
+import { OnboardingFooterProvider } from "./OnboardingFooter";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -376,7 +378,6 @@ export function OnboardingFlow({
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
const totalOnboardingSteps = activeSteps.length;
- const hideFixedProgressOnCompact = currentPage === "avatar";
// Swapping the identity changes the pubkey, which remounts this flow
// (keyed on pubkey in App.tsx) and re-runs the onboarding gate: the new
@@ -447,154 +448,137 @@ export function OnboardingFlow({
return (
<>
-
-
+
+
-
-
-
- {membershipError &&
- (currentPage === "profile" || currentPage === "avatar") ? (
-
- {membershipError.kind === "unreachable" ? (
- <>
-
- Can't reach this relay
-
-
- Check your connection or change your community.
-
-
setIsCommunityChangeOpen(true)}
- size="sm"
- variant="outline"
- >
- Change community
-
- >
- ) : (
- <>
-
- {membershipError.message ?? "Something went wrong"}
-
-
- The relay returned an error. Try again.
-
- >
- )}
-
- ) : null}
-
- {currentPage === "profile" ? (
-
{
- setMembershipError(null);
- setIsCommunityChangeOpen(true);
- },
- clearAvatarDraft: resetAvatarDraft,
- importExistingKey: showKeyImportPage,
- onUploadingChange: setIsUploadingAvatar,
- skipForNow,
- submit: () => {
- void saveProfileAndContinue("avatar");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- updateDisplayName: updateDisplayNameDraft,
- }}
- direction={transitionDirection}
- state={profileStepState}
- />
- ) : currentPage === "key-import" ? (
-
-
- {identityLost ? (
+ {membershipError &&
+ (currentPage === "profile" || currentPage === "avatar") ? (
+
+ {membershipError.kind === "unreachable" ? (
<>
-
- Re-import your key
-
-
- Your identity is no longer in the system keyring.
- Re-import your nsec to restore it — Buzz will restart to
- finish recovery. Or go back to start a new identity with a
- fresh key.
+
+ Can't reach this relay
+
+ Check your connection or change your community.
+
+
setIsCommunityChangeOpen(true)}
+ size="sm"
+ variant="outline"
+ >
+ Change community
+
>
) : (
<>
-
- Use your existing key
-
-
- Import your Nostr private key to use that identity with
- Buzz. If this key already has a profile on the relay, your
- name and avatar are restored automatically.
+
+ {membershipError.message ?? "Something went wrong"}
+
+
+ The relay returned an error. Try again.
>
)}
+ ) : null}
- {persistError ? (
-
- {persistError}
-
- ) : null}
-
-
{
+ setMembershipError(null);
+ setIsCommunityChangeOpen(true);
+ },
+ clearAvatarDraft: resetAvatarDraft,
+ importExistingKey: showKeyImportPage,
+ onUploadingChange: setIsUploadingAvatar,
+ skipForNow,
+ submit: () => {
+ void saveProfileAndContinue("avatar");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ updateDisplayName: updateDisplayNameDraft,
+ }}
+ direction={transitionDirection}
+ state={profileStepState}
+ usesExistingIdentity={forceFreshOnboarding}
/>
-
- ) : (
- {
- void saveProfileAndContinue("complete");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- }}
- currentStep={currentStep}
- direction={transitionDirection}
- showAlwaysSkip={true}
- state={avatarStepState}
- totalSteps={totalOnboardingSteps}
- />
- )}
-
+ ) : currentPage === "key-import" ? (
+
+
+ {identityLost ? (
+ <>
+
+ Re-import your key
+
+
+ Your identity is no longer in the system keyring.
+ Re-import your nsec to restore it — Buzz will restart to
+ finish recovery. Or go back to start a new identity with
+ a fresh key.
+
+ >
+ ) : (
+ <>
+
+ Use your existing key
+
+
+ Import your Nostr private key to use that identity with
+ Buzz. If this key already has a profile on the relay,
+ your name and avatar are restored automatically.
+
+ >
+ )}
+
+
+ {persistError ? (
+
+ {persistError}
+
+ ) : null}
+
+
+
+ ) : (
+ {
+ void saveProfileAndContinue("complete");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ }}
+ direction={transitionDirection}
+ showAlwaysSkip={true}
+ state={avatarStepState}
+ />
+ )}
+
+
{isCommunityChangeOpen ? (
(
+ null,
+);
+
+/**
+ * Renders the shared, bottom-docked CTA slot for an onboarding shell and
+ * exposes it to descendant steps via context.
+ *
+ * The slot is a direct child of the shell — a sibling of the animated step
+ * content — so CTAs portaled into it through `OnboardingFooter` escape
+ * `OnboardingSlideTransition`'s transform. A transformed ancestor establishes a
+ * containing block that would otherwise trap `position: fixed`, which is why
+ * the CTAs can't simply live inside the step and use `fixed` themselves. The
+ * slot stays inside the `.buzz-onboarding-neutral-theme` subtree so
+ * `--buzz-welcome-chartreuse` and the theme color tokens still resolve for the
+ * docked buttons.
+ */
+export function OnboardingFooterProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [target, setTarget] = React.useState(null);
+
+ return (
+
+ {children}
+ {/* Scrim: on pages taller than the viewport, content scrolls under the
+ docked CTA. This bottom-anchored fade to the shell's bottom color
+ (invisible on short pages and on the flat chartreuse landing) gives
+ the CTA a floor to sit on instead of colliding with form fields. */}
+
+
+
+ );
+}
+
+/**
+ * Portals a step's primary CTA group into the shell's bottom-docked footer slot
+ * (see `OnboardingFooterProvider`). When no slot exists in context — screens
+ * that render onboarding steps outside a provider, e.g. `KeyringLockedScreen` —
+ * it falls back to rendering the CTA group inline so the buttons never vanish.
+ */
+export function OnboardingFooter({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const target = React.useContext(OnboardingFooterTargetContext);
+ const group = (
+
+ {children}
+
+ );
+
+ if (!target) {
+ return group;
+ }
+
+ return createPortal(group, target);
+}
diff --git a/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx b/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx
deleted file mode 100644
index b970eb5610..0000000000
--- a/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
-
-/**
- * Positions in the first-launch flow: landing, identity/key, provider setup,
- * community choice, community profile, meet the team.
- */
-export const TOTAL_ONBOARDING_PAGES = 6;
-
-/**
- * Top-left paging indicator for the first-launch flow. The bee marks the
- * current page on a dot track; shown on every page after the landing screen.
- */
-export function OnboardingStepDots({
- current,
- total = TOTAL_ONBOARDING_PAGES,
-}: {
- current: number;
- total?: number;
-}) {
- return (
-
- {Array.from({ length: total }, (_, i) => i + 1).map((position) =>
- position === current ? (
-
-
-
- ) : (
-
- ),
- )}
-
- );
-}
diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx
index 8286cf1a53..69da9b8f7c 100644
--- a/desktop/src/features/onboarding/ui/ProfileStep.tsx
+++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx
@@ -8,6 +8,8 @@ import { cn } from "@/shared/lib/cn";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
type OnboardingTransitionEffect,
@@ -20,6 +22,7 @@ type ProfileStepProps = {
direction: OnboardingTransitionDirection;
transitionEffect?: OnboardingTransitionEffect;
state: ProfileStepState;
+ usesExistingIdentity?: boolean;
};
const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500;
@@ -188,6 +191,7 @@ export function ProfileStep({
direction,
transitionEffect = "line-slide",
state,
+ usesExistingIdentity = false,
}: ProfileStepProps) {
const {
advanceWithoutSaving,
@@ -216,10 +220,10 @@ export function ProfileStep({
transitionKey={`profile-${direction}`}
>
-
+
What should we call you?
-
+
Pick the name people and agents will see in Buzz. You can change it
anytime.
@@ -275,9 +279,9 @@ export function ProfileStep({
) : null}
-
+
{isSaving ? (
+ ) : usesExistingIdentity ? (
+ "Continue"
) : (
"Create an identity key"
)}
@@ -303,16 +309,18 @@ export function ProfileStep({
) : null}
-
- I already have a key
-
+ {!usesExistingIdentity ? (
+
+ I already have a key
+
+ ) : null}
@@ -340,7 +348,7 @@ export function ProfileStep({
) : null}
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index f99feff3fc..e1eaf7203f 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -4,8 +4,8 @@ import {
AlertTriangle,
Check,
ExternalLink,
+ Info,
Plus,
- RefreshCw,
TerminalSquare,
} from "lucide-react";
@@ -15,32 +15,21 @@ import {
useGitBashPrerequisiteQuery,
} from "@/features/agents/hooks";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
-import {
- GlobalAgentConfigFields,
- EMPTY_GLOBAL_CONFIG,
-} from "@/features/agents/ui/GlobalAgentConfigFields";
-import { createSaveCoalescer } from "./saveCoalescer";
-import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
-import {
- getGlobalAgentConfig,
- setGlobalAgentConfig,
-} from "@/shared/api/tauriGlobalAgentConfig";
-import type {
- AcpRuntimeCatalogEntry,
- GlobalAgentConfig,
-} from "@/shared/api/types";
+import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { SetupStepActions, SetupStepState } from "./types";
-import { resolveAgentReadiness } from "./agentReadiness";
type SetupStepProps = {
actions: SetupStepActions;
@@ -58,150 +47,6 @@ type InstallResultState = {
success: boolean;
};
-function AgentDefaultsSection() {
- const runtimesQuery = useAcpRuntimesQuery();
- const [config, setConfig] =
- React.useState
(EMPTY_GLOBAL_CONFIG);
- const [isLoading, setIsLoading] = React.useState(true);
- const [isCustomProvider, setIsCustomProvider] = React.useState(false);
- const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
- const [bakedEnv, setBakedEnv] = React.useState([]);
- const coalescerRef = React.useRef<{
- enqueue: (value: GlobalAgentConfig) => void;
- cancel: () => void;
- } | null>(null);
-
- React.useEffect(() => {
- let unmounted = false;
-
- getGlobalAgentConfig()
- .then((loaded) => {
- if (!unmounted) {
- setConfig(loaded);
- setIsLoading(false);
- }
- })
- .catch(() => {
- if (!unmounted) setIsLoading(false);
- });
- getBakedBuildEnv()
- .then((env) => {
- if (!unmounted) setBakedEnv(env);
- })
- .catch(() => undefined);
-
- // The coalescer serializes autosaves and drains any edit that arrived
- // while a previous save was in flight. Cancel on unmount so a slow
- // in-flight request never calls setState on an unmounted component.
- const coalescer = createSaveCoalescer(
- // set_global_agent_config returns a save result (config + restart
- // counts); the coalescer round-trips the persisted config only.
- async (next) => (await setGlobalAgentConfig(next)).config,
- () => undefined, // saving state not surfaced in this autosave UX
- (saved) => {
- if (!unmounted) setConfig(saved);
- },
- );
- coalescerRef.current = coalescer;
-
- return () => {
- unmounted = true;
- coalescer.cancel();
- };
- }, []);
-
- const buzzAgentRuntime = React.useMemo(
- () => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
- [runtimesQuery.data],
- );
-
- const readiness = resolveAgentReadiness(runtimesQuery.data ?? [], config);
-
- return (
-
-
-
-
- Agent defaults
-
-
- Configure the LLM provider and credentials that buzz-agent uses, or
- connect a CLI harness like Claude or Goose above.
-
-
-
-
- {readiness.ready ? (
-
- {readiness.reason === "cli"
- ? `${readiness.runtimeLabel} ready`
- : "buzz-agent configured"}
-
- ) : (
-
- Not configured
-
- )}
- void runtimesQuery.refetch()}
- size="sm"
- type="button"
- variant="ghost"
- >
- {runtimesQuery.isFetching ? (
-
- ) : (
-
- )}
- Re-check
-
-
-
-
- {isLoading ? (
-
-
- Loading…
-
- ) : (
- {
- // Always apply optimistically so the UI never reverts mid-save,
- // then enqueue the persist — the coalescer serialises multiple
- // rapid edits into a single trailing request.
- setConfig(next);
- coalescerRef.current?.enqueue(next);
- }}
- onCustomModelEditingChange={setIsCustomModelEditing}
- onIsCustomProviderChange={setIsCustomProvider}
- />
- )}
-
- {!readiness.ready ? (
-
- You can finish now and configure agents later in Settings.
-
- ) : null}
-
- );
-}
-
function useSetupStepState(): SetupStepState {
const runtimesQuery = useAcpRuntimesQuery();
const items = runtimesQuery.data ?? [];
@@ -225,25 +70,24 @@ function RuntimeIcon({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.avatarUrl && !imageFailed) {
return (
-
-
setImageFailed(true)}
- src={runtime.avatarUrl}
- />
-
+ setImageFailed(true)}
+ src={runtime.avatarUrl}
+ />
);
}
return (
-
-
-
+
);
}
@@ -264,7 +108,7 @@ function RuntimeStatus({
return (
@@ -274,7 +118,7 @@ function RuntimeStatus({
if (installError) {
return (
-
+
);
@@ -282,8 +126,15 @@ function RuntimeStatus({
if (runtime.availability === "available" || installSuccess) {
return (
-
-
+
+
);
}
@@ -292,7 +143,7 @@ function RuntimeStatus({
return (
void openUrl(runtime.installInstructionsUrl)}
size="icon"
@@ -331,7 +182,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
);
return (
<>
-
+
{description.charAt(0).toUpperCase() + description.slice(1)}
{runtime.defaultArgs.length > 0 ? (
@@ -347,7 +198,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "adapter_missing") {
return (
<>
-
+
CLI detected; ACP adapter missing.
@@ -360,7 +211,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "adapter_outdated") {
return (
<>
-
+
ACP adapter detected but outdated — reinstall required.
@@ -383,7 +234,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "cli_missing") {
return (
<>
-
+
ACP adapter detected; CLI missing.
@@ -395,7 +246,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
return (
<>
-
+
Not installed yet.
@@ -405,6 +256,30 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
);
}
+function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
+ if (
+ runtime.availability === "available" &&
+ runtime.command &&
+ runtime.binaryPath
+ ) {
+ const description = describeResolvedCommand(
+ runtime.command,
+ runtime.binaryPath,
+ );
+ return description.charAt(0).toUpperCase() + description.slice(1);
+ }
+ if (runtime.availability === "adapter_missing") {
+ return "CLI detected; ACP adapter missing.";
+ }
+ if (runtime.availability === "adapter_outdated") {
+ return "ACP adapter detected but outdated — reinstall required.";
+ }
+ if (runtime.availability === "cli_missing") {
+ return "ACP adapter detected; CLI missing.";
+ }
+ return "Not installed yet.";
+}
+
function RuntimeCard({
installError,
installSuccess,
@@ -423,54 +298,64 @@ function RuntimeCard({
return (
+
+
+
+
+
+
-
-
- {runtime.label}
-
- {isAvailable ? (
-
- Installed
-
- ) : null}
-
-
-
-
+
+ {runtime.label}
+
+ {!isAvailable && !installError ? (
+
+ {runtimeDetailText(runtime)}
+
+ ) : null}
{installError ? (
-
+
{installError}
) : null}
-
{installSuccess && runtime.availability !== "available" ? (
-
- Installed successfully. You can finish onboarding now.
-
+
Installed
) : null}
-
-
);
}
@@ -483,10 +368,10 @@ function GitBashPrerequisiteCard() {
return (
@@ -572,23 +457,21 @@ function RuntimeProvidersSection({
}
return (
-
-
-
-
- Agent harnesses
-
-
- Buzz can launch local ACP-compatible agent harnesses. Install or
- verify the runtimes this desktop app can see.
-
-
+
+
+
+ Use the models that fit the task
+
+
+ These are the local agent harnesses Buzz detected. You choose a
+ harness when creating each agent.
+
{items.length > 0 ? (
-
+
{items.map((runtime) => (
) : isChecking ? (
-
+
Looking for compatible runtimes...
) : errorMessage ? null : (
No compatible ACP runtimes detected yet. You can finish setup now and
@@ -618,7 +501,7 @@ function RuntimeProvidersSection({
)}
{errorMessage ? (
-
+
{errorMessage}
) : null}
@@ -635,27 +518,27 @@ function SetupStepContent({
return (
-
-
-
+
- Finish
+ Next
Back
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts
index 78eda6533b..149e453b65 100644
--- a/desktop/src/features/onboarding/ui/types.ts
+++ b/desktop/src/features/onboarding/ui/types.ts
@@ -57,6 +57,11 @@ export type ProfileStepActions = {
};
export type SetupStepActions = {
+ back: () => void;
+ next: () => void;
+};
+
+export type DefaultConfigStepActions = {
back: () => void;
complete: () => void;
};
diff --git a/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
new file mode 100644
index 0000000000..acd83fe062
--- /dev/null
+++ b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { useWelcomeKickoff } from "@/features/onboarding/welcomeKickoff";
+import type { Channel, RelayEvent } from "@/shared/api/types";
+
+/**
+ * Runs the Welcome kickoff choreography and tracks the one-shot entrance
+ * animation for the newly posted opener.
+ *
+ * The opener lands in the main timeline with a one-shot entrance; the user
+ * opens the thread themselves. Auto-opening it caused a cascade of loading
+ * states (thread skeleton, "No replies" flash) on first run.
+ */
+export function useWelcomeKickoffEntrance(
+ activeChannel: Channel | null,
+ resolvedMessages: readonly RelayEvent[],
+ threadReplyEvents: readonly RelayEvent[],
+) {
+ const welcomeKickoffEvents = React.useMemo(
+ () => [...resolvedMessages, ...threadReplyEvents],
+ [resolvedMessages, threadReplyEvents],
+ );
+ const [entranceMessageId, setEntranceMessageId] = React.useState<
+ string | null
+ >(null);
+ React.useEffect(() => {
+ void activeChannel?.id;
+ setEntranceMessageId(null);
+ }, [activeChannel?.id]);
+ const handleKickoffOpenerPosted = React.useCallback((eventId: string) => {
+ setEntranceMessageId(eventId);
+ }, []);
+ const handleEntranceComplete = React.useCallback((eventId: string) => {
+ setEntranceMessageId((current) => (current === eventId ? null : current));
+ }, []);
+ useWelcomeKickoff(
+ activeChannel,
+ welcomeKickoffEvents,
+ handleKickoffOpenerPosted,
+ );
+
+ return { entranceMessageId, handleEntranceComplete };
+}
diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs
index b09ceb0946..e0a57a4586 100644
--- a/desktop/src/features/onboarding/welcome.test.mjs
+++ b/desktop/src/features/onboarding/welcome.test.mjs
@@ -96,6 +96,28 @@ test("ensureWelcomeChannel creates a private Welcome channel when one is missing
]);
});
+test("ensureWelcomeChannel replaces existing Welcome in forced-fresh development mode", async () => {
+ const existingChannel = makeChannel({ id: "old-welcome" });
+ const calls = [];
+
+ const result = await ensureWelcomeChannel(
+ {
+ getChannels: async () => [existingChannel],
+ deleteChannel: async (channelId) => {
+ calls.push(["delete", channelId]);
+ },
+ createChannel: async () => {
+ calls.push(["create"]);
+ return makeChannel({ id: "fresh-welcome" });
+ },
+ },
+ { replaceExisting: true },
+ );
+
+ assert.equal(result.id, "fresh-welcome");
+ assert.deepEqual(calls, [["delete", "old-welcome"], ["create"]]);
+});
+
test("ensureWelcomeChannel clears ttl on an existing ephemeral Welcome channel", async () => {
const existingChannel = makeChannel({
description:
diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts
index a60135e1db..98dc49cf06 100644
--- a/desktop/src/features/onboarding/welcome.ts
+++ b/desktop/src/features/onboarding/welcome.ts
@@ -26,6 +26,7 @@ const WELCOME_CHANNEL_ENSURED_STORAGE_KEY = "buzz-welcome-channel-ensured.v2";
type WelcomeChannelClient = {
createChannel: (input: CreateChannelInput) => Promise
;
+ deleteChannel?: (channelId: string) => Promise;
getChannels: () => Promise;
getChannelMembers?: (channelId: string) => Promise;
updateChannel?: (input: UpdateChannelInput) => Promise;
@@ -44,6 +45,8 @@ export type StarterChannelsResult = {
type WelcomeChannelOptions = {
allowedMemberPubkeys?: readonly string[];
+ /** Development-only replay mode: replace Welcome instead of reusing history. */
+ replaceExisting?: boolean;
};
type PendingWelcomeChannel = {
@@ -225,12 +228,26 @@ export async function ensureWelcomeChannel(
) {
const channels = await client.getChannels();
const existingWelcome = findPrivateWelcomeChannel(channels, options);
+ if (options.replaceExisting && existingWelcome) {
+ if (!client.deleteChannel) {
+ throw new Error("Replacing Welcome requires deleteChannel.");
+ }
+ await client.deleteChannel(existingWelcome.id);
+ return client.createChannel(welcomeChannelInput);
+ }
if (existingWelcome) {
return ensureCurrentWelcomeChannelMetadata(client, existingWelcome);
}
for (const channel of channels.filter(isPrivateWelcomeChannelCandidate)) {
if (await hasOnlyCurrentHumanMember(client, channel)) {
+ if (options.replaceExisting) {
+ if (!client.deleteChannel) {
+ throw new Error("Replacing Welcome requires deleteChannel.");
+ }
+ await client.deleteChannel(channel.id);
+ return client.createChannel(welcomeChannelInput);
+ }
return ensureCurrentWelcomeChannelMetadata(client, channel);
}
}
diff --git a/desktop/src/features/onboarding/welcomeCanvas.test.mjs b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
new file mode 100644
index 0000000000..8e094937ea
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ ensureWelcomeCanvas,
+ WELCOME_CANVAS_CONTENT,
+} from "./welcomeCanvas.ts";
+
+test("welcome canvas covers purpose, agent use, a first challenge, and help", () => {
+ assert.match(WELCOME_CANVAS_CONTENT, /private channel is your home base/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /Mention an agent/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /quick challenge/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /Buzz user guide/i);
+});
+
+test("ensureWelcomeCanvas seeds a fresh channel with no canvas", async () => {
+ const writes = [];
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({ content: "", updatedAt: null, author: null }),
+ setCanvas: async (input) => {
+ writes.push(input);
+ return { ok: true, eventId: "e1" };
+ },
+ });
+
+ assert.equal(seeded, true);
+ assert.deepEqual(writes, [
+ { channelId: "welcome-1", content: WELCOME_CANVAS_CONTENT },
+ ]);
+});
+
+test("ensureWelcomeCanvas seeds even when the backend omits the empty-state fields", async () => {
+ // Regression: get_canvas once returned `{ content: "" }` with updated_at and
+ // author absent (undefined). `!== null` treated that as an existing canvas
+ // and seeding silently never ran for any fresh channel.
+ const writes = [];
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({ content: "" }),
+ setCanvas: async (input) => {
+ writes.push(input);
+ return { ok: true, eventId: "e1" };
+ },
+ });
+
+ assert.equal(seeded, true);
+ assert.equal(writes.length, 1);
+});
+
+test("ensureWelcomeCanvas never overwrites an existing canvas", async () => {
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({
+ content: "my notes",
+ updatedAt: 1_700_000_000,
+ author: "a".repeat(64),
+ }),
+ setCanvas: async () => {
+ throw new Error("must not overwrite an existing canvas");
+ },
+ });
+
+ assert.equal(seeded, false);
+});
diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts
new file mode 100644
index 0000000000..7d592fe30a
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.ts
@@ -0,0 +1,41 @@
+import { getCanvas, setCanvas } from "@/shared/api/tauri";
+
+export const WELCOME_CANVAS_CONTENT = `# Welcome to Buzz
+
+This private channel is your home base for getting oriented. Fizz, Honey, and Bumble can help you learn the app, troubleshoot setup, and work through something you are building.
+
+## Work with your agents
+
+- Mention an agent when you want its help.
+- Bring multiple agents into the same conversation when you want different perspectives.
+- Keep decisions, progress, and results in the channel so everyone shares the same context.
+
+## Try something
+
+Bring the team something you are building, or give them a quick challenge to see how they work together.
+
+## Get help
+
+Ask the team a question here, or read the [Buzz user guide](https://github.com/block/buzz#readme).
+`;
+
+type WelcomeCanvasClient = {
+ getCanvas: typeof getCanvas;
+ setCanvas: typeof setCanvas;
+};
+
+/** Seed the Welcome canvas without overwriting anything the user has written. */
+export async function ensureWelcomeCanvas(
+ channelId: string,
+ client: WelcomeCanvasClient = { getCanvas, setCanvas },
+) {
+ const existing = await client.getCanvas(channelId);
+ // Nullish (not `!== null`) so an absent field can never masquerade as an
+ // existing canvas — that exact mismatch silently skipped seeding before.
+ if (existing.updatedAt != null || existing.author != null) {
+ return false;
+ }
+
+ await client.setCanvas({ channelId, content: WELCOME_CANVAS_CONTENT });
+ return true;
+}
diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs
index 49a717d447..4129f938af 100644
--- a/desktop/src/features/onboarding/welcomeGuide.test.mjs
+++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs
@@ -2,11 +2,15 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ activateWelcomeTeamPersonasSequentially,
LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
+ pickWelcomeTeamStarterAgentForRelay,
WELCOME_GUIDE_AGENT_NAME,
WELCOME_GUIDE_PERSONA_ID,
+ WELCOME_TEAM_ID,
+ WELCOME_TEAM_STARTERS,
} from "./welcomeGuide.ts";
const PUB_A = "a".repeat(64);
@@ -46,6 +50,7 @@ function makeAgent(overrides = {}) {
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
+ teamId: WELCOME_TEAM_ID,
...overrides,
};
}
@@ -128,3 +133,112 @@ test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in anothe
null,
);
});
+
+test("starter persona activation is serialized to protect the shared store", async () => {
+ const calls = [];
+ let activeWrites = 0;
+
+ await activateWelcomeTeamPersonasSequentially(
+ ["builtin:fizz", "builtin:honey", "builtin:bumble"],
+ async (personaId) => {
+ assert.equal(activeWrites, 0, "activation writes must never overlap");
+ activeWrites += 1;
+ calls.push(personaId);
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ activeWrites -= 1;
+ },
+ );
+
+ assert.deepEqual(calls, ["builtin:fizz", "builtin:honey", "builtin:bumble"]);
+});
+
+test("welcome team starter definitions and role identities are stable", () => {
+ assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome");
+ assert.deepEqual(WELCOME_TEAM_STARTERS, [
+ { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+ { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+ { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+ ]);
+});
+
+test("starter matching ignores user agents with a Welcome persona", () => {
+ const honey = WELCOME_TEAM_STARTERS[1];
+ const userHoney = makeAgent({
+ personaId: honey.personaId,
+ teamId: null,
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay([userHoney], honey, RELAY_A),
+ null,
+ );
+});
+
+test("starter matching uses persona identity rather than display name", () => {
+ const honey = WELCOME_TEAM_STARTERS[1];
+ const renamedHoney = makeAgent({
+ name: "Honey the Helper",
+ personaId: honey.personaId,
+ });
+ const nameOnlyHoney = makeAgent({ name: honey.name, pubkey: PUB_B });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [nameOnlyHoney, renamedHoney],
+ honey,
+ RELAY_A,
+ ),
+ renamedHoney,
+ );
+});
+
+test("starter matching is relay scoped and normalizes trailing slashes", () => {
+ const bumble = WELCOME_TEAM_STARTERS[2];
+ const otherRelay = makeAgent({
+ personaId: bumble.personaId,
+ relayUrl: RELAY_B,
+ status: "running",
+ });
+ const matchingRelay = makeAgent({
+ personaId: bumble.personaId,
+ relayUrl: `${RELAY_A}/`,
+ pubkey: PUB_B,
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [otherRelay, matchingRelay],
+ bumble,
+ RELAY_A,
+ ),
+ matchingRelay,
+ );
+});
+
+test("starter matching prefers running, then deployed instances", () => {
+ const fizz = WELCOME_TEAM_STARTERS[0];
+ const stopped = makeAgent({ personaId: fizz.personaId });
+ const deployed = makeAgent({
+ personaId: fizz.personaId,
+ pubkey: PUB_B,
+ status: "deployed",
+ });
+ const running = makeAgent({
+ personaId: fizz.personaId,
+ pubkey: PUB_C,
+ status: "running",
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [stopped, deployed, running],
+ fizz,
+ RELAY_A,
+ ),
+ running,
+ );
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay([stopped, deployed], fizz, RELAY_A),
+ deployed,
+ );
+});
diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts
index 051cfb8c7a..f0fb185c36 100644
--- a/desktop/src/features/onboarding/welcomeGuide.ts
+++ b/desktop/src/features/onboarding/welcomeGuide.ts
@@ -3,14 +3,15 @@ import {
createManagedAgent,
getChannelMembers,
listManagedAgents,
+ updateManagedAgent,
} from "@/shared/api/tauri";
-import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type { ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const WELCOME_GUIDE_AGENT_NAME = "Fizz";
export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
+export const WELCOME_TEAM_ID = "builtin-team:welcome";
export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1";
const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit";
export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
@@ -18,6 +19,25 @@ export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
export const WELCOME_GUIDE_INTRO_MESSAGE =
"Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.";
+export type WelcomeTeamRole = "lead" | "teammate";
+
+export type WelcomeTeamStarterDefinition = Readonly<{
+ name: string;
+ personaId: string;
+ role: WelcomeTeamRole;
+}>;
+
+/** Stable identities used to provision the Rust-seeded Welcome Team. */
+export const WELCOME_TEAM_STARTERS = [
+ { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+ { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+ { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+] as const satisfies readonly WelcomeTeamStarterDefinition[];
+
+export type WelcomeTeamAgents = [ManagedAgent, ManagedAgent, ManagedAgent];
+
+const welcomeTeamPromises = new Map>();
+
function normalizeRelayUrl(relayUrl: string | null | undefined) {
return relayUrl?.trim().replace(/\/+$/, "") ?? null;
}
@@ -30,17 +50,8 @@ function isAgentScopedToRelay(agent: ManagedAgent, relayUrl?: string | null) {
return normalizeRelayUrl(agent.relayUrl) === targetRelayUrl;
}
-function isNamedWelcomeGuideAgent(agent: ManagedAgent) {
- return (
- agent.name.trim().toLowerCase() === WELCOME_GUIDE_AGENT_NAME.toLowerCase()
- );
-}
-
function isBuiltInWelcomeGuideAgent(agent: ManagedAgent) {
- return (
- agent.personaId === WELCOME_GUIDE_PERSONA_ID &&
- isNamedWelcomeGuideAgent(agent)
- );
+ return agent.personaId === WELCOME_GUIDE_PERSONA_ID;
}
function isLegacyKitWelcomeGuideAgent(agent: ManagedAgent) {
@@ -82,6 +93,39 @@ export function pickWelcomeGuideAgentForRelay(
);
}
+/** Find the preferred managed instance for one starter persona and relay. */
+export function pickWelcomeTeamStarterAgentForRelay(
+ agents: ManagedAgent[],
+ starter: WelcomeTeamStarterDefinition,
+ relayUrl?: string | null,
+) {
+ return pickAgentByStatus(
+ agents.filter(
+ (agent) =>
+ agent.teamId === WELCOME_TEAM_ID &&
+ agent.personaId === starter.personaId &&
+ isAgentScopedToRelay(agent, relayUrl),
+ ),
+ );
+}
+
+/** Pubkeys belonging to any managed Welcome Team persona on this relay. */
+export async function getWelcomeTeamAgentPubkeys(relayUrl?: string | null) {
+ const personaIds = new Set(
+ WELCOME_TEAM_STARTERS.map(({ personaId }) => personaId),
+ );
+ return (await listManagedAgents())
+ .filter(
+ (agent) =>
+ agent.teamId === WELCOME_TEAM_ID &&
+ agent.personaId !== null &&
+ personaIds.has(agent.personaId) &&
+ isAgentScopedToRelay(agent, relayUrl),
+ )
+ .map((agent) => agent.pubkey);
+}
+
+/** Legacy Fizz/Kit lookup retained for existing channel reuse checks. */
export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
return (await listManagedAgents())
.filter(
@@ -91,76 +135,138 @@ export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
.map((agent) => agent.pubkey);
}
-async function ensureWelcomeGuidePersonaActive() {
- const guidePersona = (await listPersonas()).find(
- (persona) => persona.id === WELCOME_GUIDE_PERSONA_ID,
- );
- if (!guidePersona) {
- throw new Error(`${WELCOME_GUIDE_AGENT_NAME} agent not found.`);
- }
- if (!guidePersona.isActive) {
- await setPersonaActive(WELCOME_GUIDE_PERSONA_ID, true);
- }
-}
-
-async function ensureWelcomeGuideAgent(relayUrl?: string | null) {
- const agents = await listManagedAgents();
- const existing = pickWelcomeGuideAgentForRelay(agents, relayUrl);
- if (existing) {
- return existing;
- }
-
- await ensureWelcomeGuidePersonaActive();
-
- const created = await createManagedAgent({
- name: WELCOME_GUIDE_AGENT_NAME,
- personaId: WELCOME_GUIDE_PERSONA_ID,
- relayUrl: relayUrl ?? undefined,
- spawnAfterCreate: false,
- startOnAppLaunch: false,
- respondTo: "owner-only",
- });
-
- return created.agent;
-}
-
-async function ensureWelcomeGuideMembership(
- channelId: string,
- agent: ManagedAgent,
+export async function activateWelcomeTeamPersonasSequentially(
+ inactivePersonaIds: readonly string[],
+ activate: (personaId: string) => Promise,
+) {
+ for (const personaId of inactivePersonaIds) {
+ await activate(personaId);
+ }
+}
+
+async function ensureWelcomeTeamPersonasActive() {
+ const personas = await listPersonas();
+ const personasById = new Map(
+ personas.map((persona) => [persona.id, persona]),
+ );
+
+ for (const starter of WELCOME_TEAM_STARTERS) {
+ if (!personasById.has(starter.personaId)) {
+ throw new Error(`${starter.name} agent not found.`);
+ }
+ }
+
+ // Persona activation is a read-modify-write operation over one shared file.
+ // Run these sequentially so concurrent writes cannot lose a teammate's
+ // activation and leave Welcome provisioning permanently partial.
+ await activateWelcomeTeamPersonasSequentially(
+ WELCOME_TEAM_STARTERS.filter(
+ ({ personaId }) => !personasById.get(personaId)?.isActive,
+ ).map(({ personaId }) => personaId),
+ (personaId) => setPersonaActive(personaId, true),
+ );
+}
+
+async function ensureWelcomeTeamMembership(
+ channelId: string,
+ agents: WelcomeTeamAgents,
) {
- const agentPubkey = normalizePubkey(agent.pubkey);
const members = await getChannelMembers(channelId).catch(() => []);
- if (
- members.some((member) => normalizePubkey(member.pubkey) === agentPubkey)
- ) {
+ const memberPubkeys = new Set(
+ members.map((member) => normalizePubkey(member.pubkey)),
+ );
+ const missingAgents = agents.filter(
+ (agent) => !memberPubkeys.has(normalizePubkey(agent.pubkey)),
+ );
+ if (missingAgents.length === 0) {
return;
}
const result = await addChannelMembers({
channelId,
- pubkeys: [agent.pubkey],
+ pubkeys: missingAgents.map((agent) => agent.pubkey),
role: "bot",
});
- const error = result.errors.find(
- (entry) => normalizePubkey(entry.pubkey) === agentPubkey,
+ const unexpectedError = result.errors.find(
+ ({ error }) => !error.toLowerCase().includes("already"),
);
- if (error && !error.error.toLowerCase().includes("already")) {
- throw new Error(error.error);
+ if (unexpectedError) {
+ throw new Error(unexpectedError.error);
}
}
-export async function ensureWelcomeGuideIntro(
+/**
+ * Ensure the complete built-in Welcome Team is ready for kickoff.
+ * The team itself is Rust-seeded; this only activates personas, creates any
+ * missing relay-scoped instances, and adds all three to Welcome as bots.
+ */
+async function provisionWelcomeTeam(
channelId: string,
relayUrl?: string | null,
-) {
- const agent = await ensureWelcomeGuideAgent(relayUrl);
- await ensureWelcomeGuideMembership(channelId, agent);
- await sendManagedAgentChannelMessage({
- agentPubkey: agent.pubkey,
- channelId,
- content: WELCOME_GUIDE_INTRO_MESSAGE,
- marker: WELCOME_GUIDE_INTRO_MARKER,
- markerScope: "channel",
- });
- return agent;
+): Promise {
+ const existingAgents = await listManagedAgents();
+ await ensureWelcomeTeamPersonasActive();
+
+ const agents: ManagedAgent[] = [];
+ for (const starter of WELCOME_TEAM_STARTERS) {
+ const existing = pickWelcomeTeamStarterAgentForRelay(
+ existingAgents,
+ starter,
+ relayUrl,
+ );
+ if (existing) {
+ agents.push(existing);
+ continue;
+ }
+
+ const created = await createManagedAgent({
+ name: starter.name,
+ personaId: starter.personaId,
+ teamId: WELCOME_TEAM_ID,
+ relayUrl: relayUrl ?? undefined,
+ spawnAfterCreate: false,
+ startOnAppLaunch: false,
+ respondTo: "owner-only",
+ });
+ agents.push(created.agent);
+ }
+ const [lead, honey, bumble] = agents;
+ if (!lead || !honey || !bumble) {
+ throw new Error("Welcome Team provisioning did not return every starter.");
+ }
+ const welcomeAgents: WelcomeTeamAgents = [lead, honey, bumble];
+ const leadPubkey = lead.pubkey;
+ for (const index of [1, 2] as const) {
+ const teammate = welcomeAgents[index];
+ const alreadyAllowsLead =
+ teammate.respondTo === "allowlist" &&
+ teammate.respondToAllowlist.some(
+ (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+ );
+ if (!alreadyAllowsLead) {
+ const updated = await updateManagedAgent({
+ pubkey: teammate.pubkey,
+ respondTo: "allowlist",
+ respondToAllowlist: [leadPubkey],
+ });
+ welcomeAgents[index] = updated.agent;
+ }
+ }
+ await ensureWelcomeTeamMembership(channelId, welcomeAgents);
+ return welcomeAgents;
+}
+
+export function ensureWelcomeTeam(
+ channelId: string,
+ relayUrl?: string | null,
+): Promise {
+ const key = `${normalizeRelayUrl(relayUrl) ?? ""}:${channelId}`;
+ const current = welcomeTeamPromises.get(key);
+ if (current) return current;
+
+ const promise = provisionWelcomeTeam(channelId, relayUrl).finally(() =>
+ welcomeTeamPromises.delete(key),
+ );
+ welcomeTeamPromises.set(key, promise);
+ return promise;
}
diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
new file mode 100644
index 0000000000..244d6de05e
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
@@ -0,0 +1,297 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ areWelcomeTeammatesOnline,
+ buildWelcomeKickoffCloser,
+ buildWelcomeKickoffOpener,
+ buildWelcomeKickoffOpenerSendInput,
+ classifyWelcomeKickoffResolution,
+ createWelcomeKickoffCoordinator,
+ resolveWelcomeAgentSet,
+ selectWelcomeKickoffIntroTeammates,
+ waitForWelcomeKickoffBeat,
+ waitForWelcomeTeammatesOnline,
+ welcomeTeammateNeedsRestart,
+} from "./welcomeKickoff.ts";
+
+function agent(name, personaId, pubkey) {
+ return {
+ name,
+ personaId,
+ teamId: "builtin-team:welcome",
+ pubkey,
+ relayUrl: "ws://localhost:3000",
+ status: "stopped",
+ lastError: null,
+ lastStartedAt: null,
+ };
+}
+
+const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64));
+const honey = agent("Honey", "builtin:honey", "h".repeat(64));
+const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64));
+
+test("resolveWelcomeAgentSet orders agents by stable persona identity", () => {
+ assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), {
+ lead: fizz,
+ teammates: [honey, bumble],
+ });
+ assert.equal(resolveWelcomeAgentSet([fizz, honey]), null);
+});
+
+test("opener uses current agent names and requests bounded simultaneous intros", () => {
+ const opener = buildWelcomeKickoffOpener({ ...fizz, name: "Fizzy" }, [
+ { ...honey, name: "Honeybee" },
+ bumble,
+ ]);
+
+ assert.match(opener, /I'm Fizzy/);
+ assert.match(opener, /@Honeybee and @Bumble/);
+ assert.doesNotMatch(opener, /@@/);
+ assert.match(opener, /sentence or two/);
+ assert.match(opener, /Don't start any work yet/);
+});
+
+test("teammates are not ready until every harness publishes online presence", () => {
+ assert.equal(areWelcomeTeammatesOnline([honey, bumble], undefined), false);
+ assert.equal(
+ areWelcomeTeammatesOnline([honey, bumble], {
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "offline",
+ }),
+ false,
+ );
+ assert.equal(
+ areWelcomeTeammatesOnline([honey, bumble], {
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "online",
+ }),
+ true,
+ );
+});
+
+test("readiness wait observes agents becoming online without navigation", async () => {
+ let reads = 0;
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => {
+ reads += 1;
+ return reads < 3
+ ? { [honey.pubkey]: "online", [bumble.pubkey]: "offline" }
+ : { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, [honey, bumble]);
+ assert.equal(reads, 3);
+});
+
+test("readiness wait retries transient presence failures", async () => {
+ let reads = 0;
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => {
+ reads += 1;
+ if (reads === 1) throw new Error("relay unavailable");
+ return { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, [honey, bumble]);
+ assert.equal(reads, 2);
+});
+
+test("readiness wait cancels when Welcome loses focus", async () => {
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => true,
+ loadPresence: async () => {
+ throw new Error("cancelled waits must not query");
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, []);
+});
+
+test("kickoff beat waits for the configured pacing interval", async () => {
+ const startedAt = Date.now();
+ assert.equal(await waitForWelcomeKickoffBeat({ waitMs: 10 }), true);
+ assert.ok(Date.now() - startedAt >= 8);
+});
+
+test("kickoff beat cancels when Welcome loses focus", async () => {
+ const controller = new AbortController();
+ const beat = waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: 1_000,
+ });
+ controller.abort();
+ assert.equal(await beat, false);
+});
+
+test("kickoff coordinator preserves one task across rerenders and cancels on navigation", () => {
+ const coordinator = createWelcomeKickoffCoordinator();
+ const first = coordinator.begin("welcome");
+ assert.ok(first);
+ assert.equal(coordinator.begin("welcome"), null);
+ assert.equal(first.signal.aborted, false);
+
+ coordinator.cancel("welcome");
+ assert.equal(first.signal.aborted, true);
+ assert.ok(coordinator.begin("welcome"));
+});
+
+test("closer degrades coherently for partial and total startup failure", () => {
+ assert.match(buildWelcomeKickoffCloser([]), /What can we help you build/);
+ assert.match(buildWelcomeKickoffCloser(["Honey"]), /Honey is having trouble/);
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+ /Honey and Bumble couldn't start/,
+ );
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+ /I'm still here to help/,
+ );
+});
+
+test("closer names teammates that did not reply before the intro wait", () => {
+ assert.match(
+ buildWelcomeKickoffCloser([], ["Bumble"]),
+ /Bumble is taking longer to reply/,
+ );
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey"], ["Bumble"]),
+ /Honey and Bumble are taking longer than expected/,
+ );
+});
+
+test("running teammates restart when their allowlist does not include the lead", () => {
+ assert.equal(
+ welcomeTeammateNeedsRestart(
+ {
+ ...honey,
+ status: "running",
+ respondTo: "allowlist",
+ respondToAllowlist: [fizz.pubkey],
+ },
+ fizz.pubkey,
+ ),
+ false,
+ );
+ assert.equal(
+ welcomeTeammateNeedsRestart(
+ {
+ ...bumble,
+ status: "running",
+ respondTo: "allowlist",
+ respondToAllowlist: [honey.pubkey],
+ },
+ fizz.pubkey,
+ ),
+ true,
+ );
+});
+
+test("opener keeps partial-readiness warm and mentions only online teammates", () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const introTeammates = selectWelcomeKickoffIntroTeammates(
+ agentSet.teammates,
+ [honey],
+ );
+ const input = buildWelcomeKickoffOpenerSendInput(
+ agentSet,
+ introTeammates,
+ "welcome-1",
+ );
+
+ assert.deepEqual(input.mentionPubkeys, [honey.pubkey]);
+ assert.deepEqual(input.additionalMarkers, []);
+ assert.match(input.content, /@Honey, introduce yourself/);
+ assert.doesNotMatch(input.content, /@@/);
+ assert.doesNotMatch(
+ input.content,
+ /Bumble.*trouble|couldn't start|taking longer/i,
+ );
+});
+
+test("opener degrades to one seeded Fizz message when no teammate comes online", () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1");
+
+ assert.deepEqual(input.mentionPubkeys, []);
+ assert.equal(input.additionalMarkers.length, 1);
+ assert.match(input.content, /I'm here with Honey and Bumble/);
+ assert.match(input.content, /What can we help you build/);
+ assert.doesNotMatch(
+ input.content,
+ /introduce yourselves|trouble|couldn't start|taking longer/i,
+ );
+});
+
+test("readiness wait returns the subset that became online by the deadline", async () => {
+ const online = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => ({
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "offline",
+ }),
+ pollMs: 0,
+ waitMs: 1,
+ });
+
+ assert.deepEqual(online, [honey]);
+});
+
+function relayEvent({ id, pubkey, createdAt = 1, tags = [], content = "" }) {
+ return {
+ id,
+ pubkey,
+ created_at: createdAt,
+ kind: 9,
+ tags,
+ content,
+ sig: "sig",
+ };
+}
+
+test("closer classification sees replies that arrive during the final beat", async () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const opener = relayEvent({
+ id: "opener",
+ pubkey: fizz.pubkey,
+ tags: [["client", "buzz-welcome-kickoff.opener.v1"]],
+ });
+ const events = [opener];
+
+ const beforeBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+ assert.deepEqual(
+ beforeBeat.unresolved.map((agent) => agent.name),
+ ["Honey", "Bumble"],
+ );
+
+ const beat = waitForWelcomeKickoffBeat({ waitMs: 5 });
+ events.push(
+ relayEvent({
+ id: "honey-intro",
+ pubkey: honey.pubkey,
+ createdAt: 2,
+ tags: [
+ ["e", opener.id, "", "root"],
+ ["e", opener.id, "", "reply"],
+ ],
+ }),
+ );
+ assert.equal(await beat, true);
+
+ const afterBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+ assert.deepEqual(
+ afterBeat.unresolved.map((agent) => agent.name),
+ ["Bumble"],
+ );
+});
diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts
new file mode 100644
index 0000000000..c6e1e49874
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.ts
@@ -0,0 +1,727 @@
+import * as React from "react";
+
+import {
+ managedAgentsQueryKey,
+ useAcpRuntimesQuery,
+ useManagedAgentsQuery,
+} from "@/features/agents/hooks";
+import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import { useCommunities } from "@/features/communities/useCommunities";
+import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding";
+import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness";
+import {
+ ensureWelcomeTeam,
+ pickWelcomeTeamStarterAgentForRelay,
+ WELCOME_TEAM_STARTERS,
+ type WelcomeTeamStarterDefinition,
+} from "@/features/onboarding/welcomeGuide";
+import { isWelcomeChannel } from "@/features/onboarding/welcome";
+import { getThreadReference } from "@/features/messages/lib/threading";
+import {
+ startManagedAgent,
+ stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAgentMessageMarkers";
+import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
+import { getPresence, listManagedAgents } from "@/shared/api/tauri";
+import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { useQueryClient } from "@tanstack/react-query";
+
+export const WELCOME_KICKOFF_OPENER_MARKER = "buzz-welcome-kickoff.opener.v1";
+export const WELCOME_KICKOFF_CLOSER_MARKER = "buzz-welcome-kickoff.closer.v1";
+export const WELCOME_KICKOFF_PROVIDER_MARKER =
+ "buzz-welcome-kickoff.provider-required.v1";
+
+const openerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_OPENER_MARKER);
+const closerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_CLOSER_MARKER);
+const providerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_PROVIDER_MARKER);
+
+export const WELCOME_KICKOFF_PROVIDER_MESSAGE =
+ "To get started with agents, connect to an AI provider in Settings. Once you're connected, come back here and we'll introduce the team.";
+
+const WELCOME_KICKOFF_CTA =
+ "What can we help you build? Bring us something you're working on, or give us a quick challenge to see how we work together.";
+
+function formatAgentNames(agents: readonly ManagedAgent[]) {
+ if (agents.length === 0) return "";
+ if (agents.length === 1) return agents[0]?.name ?? "";
+ return `${agents
+ .slice(0, -1)
+ .map((agent) => agent.name)
+ .join(", ")} and ${agents[agents.length - 1]?.name ?? ""}`;
+}
+
+function formatMentionNames(agents: readonly ManagedAgent[]) {
+ if (agents.length === 0) return "";
+ if (agents.length === 1) return `@${agents[0]?.name ?? ""}`;
+ return `${agents
+ .slice(0, -1)
+ .map((agent) => `@${agent.name}`)
+ .join(", ")} and @${agents[agents.length - 1]?.name ?? ""}`;
+}
+export function createWelcomeKickoffCoordinator() {
+ const controllers = new Map();
+ return {
+ begin(channelId: string) {
+ if (controllers.has(channelId)) return null;
+ const controller = new AbortController();
+ controllers.set(channelId, controller);
+ return controller;
+ },
+ cancel(channelId: string) {
+ controllers.get(channelId)?.abort();
+ controllers.delete(channelId);
+ },
+ finish(channelId: string, controller: AbortController) {
+ if (controllers.get(channelId) === controller) {
+ controllers.delete(channelId);
+ }
+ },
+ };
+}
+
+const kickoffCoordinator = createWelcomeKickoffCoordinator();
+const closerInFlight = new Set();
+const TEAMMATE_READY_POLL_MS = 250;
+const TEAMMATE_READY_WAIT_MS = 60_000;
+const TEAMMATE_INTRO_WAIT_MS = 15_000;
+const KICKOFF_BEAT_MS = 3_000;
+const CLOSER_BEAT_MS = 10_000;
+const closerAbortControllers = new Map();
+const closerTimeouts = new Map<
+ string,
+ ReturnType
+>();
+
+type WelcomeAgentSet = {
+ lead: ManagedAgent;
+ teammates: [ManagedAgent, ManagedAgent];
+};
+
+function markerEvent(events: readonly RelayEvent[], marker: string) {
+ return events.find((event) =>
+ event.tags.some(
+ (tag) => tag.length >= 2 && tag[0] === "client" && tag[1] === marker,
+ ),
+ );
+}
+
+export function resolveWelcomeAgentSet(
+ agents: readonly ManagedAgent[],
+): WelcomeAgentSet | null {
+ const ordered = WELCOME_TEAM_STARTERS.map((starter) =>
+ pickWelcomeTeamStarterAgentForRelay([...agents], starter),
+ );
+ if (ordered.some((agent) => !agent)) return null;
+ return {
+ lead: ordered[0] as ManagedAgent,
+ teammates: [ordered[1] as ManagedAgent, ordered[2] as ManagedAgent],
+ };
+}
+
+function normalizeRelayUrl(relayUrl?: string | null) {
+ return relayUrl?.trim().replace(/\/+$/, "") ?? null;
+}
+
+function resolveWelcomeAgentSetForRelay(
+ agents: readonly ManagedAgent[],
+ relayUrl?: string | null,
+) {
+ const normalizedRelayUrl = normalizeRelayUrl(relayUrl);
+ return resolveWelcomeAgentSet(
+ agents.filter(
+ (agent) =>
+ !normalizedRelayUrl ||
+ normalizeRelayUrl(agent.relayUrl) === normalizedRelayUrl,
+ ),
+ );
+}
+
+export function buildWelcomeKickoffOpener(
+ lead: ManagedAgent,
+ introTeammates: readonly ManagedAgent[],
+ allTeammates: readonly ManagedAgent[] = introTeammates,
+) {
+ const introNames = formatMentionNames(introTeammates);
+ if (introTeammates.length === 0) {
+ const teammateNames = formatAgentNames(allTeammates);
+ const teammatePhrase = teammateNames ? ` with ${teammateNames}` : "";
+ return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and I'm here${teammatePhrase} to help you get oriented or work through something you're building.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+
+ return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and we're here to help you get oriented or work through something you're building.\n\n${introNames}, introduce ${introTeammates.length === 1 ? "yourself" : "yourselves"} in a sentence or two — share what you're good at and when to bring you in. Don't start any work yet.`;
+}
+
+export function onlineWelcomeTeammates(
+ teammates: readonly ManagedAgent[],
+ presence: Readonly> | undefined,
+) {
+ return teammates.filter(
+ (agent) => presence?.[normalizePubkey(agent.pubkey)] === "online",
+ );
+}
+
+export function areWelcomeTeammatesOnline(
+ teammates: readonly ManagedAgent[],
+ presence: Readonly> | undefined,
+) {
+ return (
+ onlineWelcomeTeammates(teammates, presence).length === teammates.length
+ );
+}
+
+export async function waitForWelcomeTeammatesOnline(
+ teammates: readonly ManagedAgent[],
+ options: {
+ isCancelled: () => boolean;
+ loadPresence?: typeof getPresence;
+ pollMs?: number;
+ waitMs?: number;
+ },
+) {
+ const loadPresence = options.loadPresence ?? getPresence;
+ const pollMs = options.pollMs ?? TEAMMATE_READY_POLL_MS;
+ const deadline = Date.now() + (options.waitMs ?? TEAMMATE_READY_WAIT_MS);
+ const pubkeys = teammates.map((agent) => agent.pubkey);
+ let latestOnline: ManagedAgent[] = [];
+
+ while (!options.isCancelled() && Date.now() < deadline) {
+ try {
+ latestOnline = onlineWelcomeTeammates(
+ teammates,
+ await loadPresence(pubkeys),
+ );
+ if (latestOnline.length === teammates.length) {
+ return latestOnline;
+ }
+ } catch (error) {
+ console.warn("Welcome teammate presence check failed; retrying.", error);
+ }
+ await new Promise((resolve) => globalThis.setTimeout(resolve, pollMs));
+ }
+ return options.isCancelled() ? [] : latestOnline;
+}
+
+export async function waitForWelcomeKickoffBeat(
+ options: { signal?: AbortSignal; waitMs?: number } = {},
+) {
+ const waitMs = options.waitMs ?? KICKOFF_BEAT_MS;
+ if (options.signal?.aborted) return false;
+
+ return new Promise((resolve) => {
+ const timer = globalThis.setTimeout(() => {
+ options.signal?.removeEventListener("abort", cancel);
+ resolve(true);
+ }, waitMs);
+ const cancel = () => {
+ globalThis.clearTimeout(timer);
+ resolve(false);
+ };
+ options.signal?.addEventListener("abort", cancel, { once: true });
+ });
+}
+
+export function buildWelcomeKickoffCloser(
+ failedNames: readonly string[],
+ delayedNames: readonly string[] = [],
+) {
+ if (failedNames.length === 0 && delayedNames.length === 0) {
+ return WELCOME_KICKOFF_CTA;
+ }
+ if (failedNames.length === 1 && delayedNames.length === 0) {
+ return `${failedNames[0]} is having trouble starting — you can check on them in Agents.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ if (failedNames.length > 1 && delayedNames.length === 0) {
+ return `${failedNames.join(" and ")} couldn't start. You can check on them in Agents; I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ if (failedNames.length === 0 && delayedNames.length === 1) {
+ return `${delayedNames[0]} is taking longer to reply — I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ const names = [...failedNames, ...delayedNames].join(" and ");
+ return `${names} are taking longer than expected. I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+}
+
+function isReplyToOpener(event: RelayEvent, opener: RelayEvent) {
+ const threadRef = getThreadReference(event.tags);
+ return threadRef.rootId === opener.id || threadRef.parentId === opener.id;
+}
+
+function introAuthorsAfterOpener(
+ events: readonly RelayEvent[],
+ opener: RelayEvent,
+ teammates: readonly [ManagedAgent, ManagedAgent],
+) {
+ const authors = new Set(
+ events
+ .filter(
+ (event) =>
+ event.created_at >= opener.created_at &&
+ isReplyToOpener(event, opener),
+ )
+ .map((event) => normalizePubkey(event.pubkey)),
+ );
+ return new Set(
+ teammates
+ .filter((agent) => authors.has(normalizePubkey(agent.pubkey)))
+ .map((agent) => normalizePubkey(agent.pubkey)),
+ );
+}
+
+function failedAfterKickoff(agent: ManagedAgent, opener: RelayEvent) {
+ if (agent.status !== "stopped" || !agent.lastError || !agent.lastStoppedAt) {
+ return false;
+ }
+ return (
+ Math.floor(new Date(agent.lastStoppedAt).getTime() / 1_000) >=
+ opener.created_at
+ );
+}
+
+export function classifyWelcomeKickoffResolution(
+ events: readonly RelayEvent[],
+ opener: RelayEvent,
+ agentSet: WelcomeAgentSet,
+) {
+ const introAuthors = introAuthorsAfterOpener(
+ events,
+ opener,
+ agentSet.teammates,
+ );
+ const failed = agentSet.teammates.filter((agent) =>
+ failedAfterKickoff(agent, opener),
+ );
+ const unresolved = agentSet.teammates.filter(
+ (agent) =>
+ !introAuthors.has(normalizePubkey(agent.pubkey)) &&
+ !failed.includes(agent),
+ );
+ return { failed, unresolved };
+}
+
+async function resolveLatestWelcomeAgentSet({
+ fallback,
+ queryClient,
+ relayUrl,
+}: {
+ fallback: WelcomeAgentSet;
+ queryClient: ReturnType;
+ relayUrl?: string | null;
+}) {
+ const agents = await queryClient.fetchQuery({
+ queryKey: managedAgentsQueryKey,
+ queryFn: listManagedAgents,
+ });
+ return resolveWelcomeAgentSetForRelay(agents, relayUrl) ?? fallback;
+}
+
+async function markerExists(channelId: string, marker: string) {
+ return hasManagedAgentChannelMessageMarker({
+ channelId,
+ marker,
+ markerScope: "channel",
+ });
+}
+
+export function welcomeTeammateNeedsRestart(
+ agent: ManagedAgent,
+ leadPubkey: string,
+) {
+ return (
+ agent.status === "running" &&
+ (agent.needsRestart ||
+ agent.respondTo !== "allowlist" ||
+ !agent.respondToAllowlist.some(
+ (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+ ))
+ );
+}
+
+export function selectWelcomeKickoffIntroTeammates(
+ teammates: readonly ManagedAgent[],
+ onlineTeammates: readonly ManagedAgent[],
+) {
+ const onlinePubkeys = new Set(
+ onlineTeammates.map((agent) => normalizePubkey(agent.pubkey)),
+ );
+ return teammates.filter((agent) =>
+ onlinePubkeys.has(normalizePubkey(agent.pubkey)),
+ );
+}
+
+export function buildWelcomeKickoffOpenerSendInput(
+ agentSet: WelcomeAgentSet,
+ introTeammates: readonly ManagedAgent[],
+ channelId: string,
+) {
+ return {
+ agentPubkey: agentSet.lead.pubkey,
+ channelId,
+ content: buildWelcomeKickoffOpener(
+ agentSet.lead,
+ introTeammates,
+ agentSet.teammates,
+ ),
+ marker: openerMarker,
+ markerScope: "channel" as const,
+ mentionPubkeys: introTeammates.map((agent) => agent.pubkey),
+ additionalMarkers: introTeammates.length === 0 ? [closerMarker] : [],
+ };
+}
+
+export async function restartWelcomeTeammate(
+ agent: ManagedAgent,
+ options: {
+ stopAgent?: typeof stopManagedAgent;
+ startAgent?: typeof startManagedAgent;
+ } = {},
+) {
+ const stopAgent = options.stopAgent ?? stopManagedAgent;
+ const startAgent = options.startAgent ?? startManagedAgent;
+ if (agent.status === "running") {
+ await stopAgent(agent.pubkey);
+ }
+ return startAgent(agent.pubkey);
+}
+
+async function sendWelcomeKickoffCloser({
+ agentSet,
+ channelId,
+ content,
+ opener,
+}: {
+ agentSet: WelcomeAgentSet;
+ channelId: string;
+ content: string;
+ opener: RelayEvent;
+}) {
+ if (await markerExists(channelId, closerMarker)) return;
+
+ await sendManagedAgentChannelMessage({
+ agentPubkey: agentSet.lead.pubkey,
+ channelId,
+ content,
+ marker: closerMarker,
+ markerScope: "channel",
+ parentEventId: opener.id,
+ });
+}
+
+/** Runs the Welcome choreography only while the Welcome channel is focused. */
+export function useWelcomeKickoff(
+ activeChannel: Channel | null,
+ channelEvents: readonly RelayEvent[],
+ onKickoffOpenerPosted?: (eventId: string) => void,
+) {
+ const queryClient = useQueryClient();
+ const { activeCommunity } = useCommunities();
+ const runtimesQuery = useAcpRuntimesQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig();
+ const channelId = activeChannel?.id ?? null;
+ const isActiveWelcome = isWelcomeChannel(activeChannel);
+ const focusedWelcomeChannelRef = React.useRef(null);
+ focusedWelcomeChannelRef.current = isActiveWelcome ? channelId : null;
+ const channelEventsRef = React.useRef(channelEvents);
+ channelEventsRef.current = channelEvents;
+ const agentSet = React.useMemo(
+ () =>
+ resolveWelcomeAgentSetForRelay(
+ managedAgentsQuery.data ?? [],
+ activeCommunity?.relayUrl,
+ ),
+ [activeCommunity?.relayUrl, managedAgentsQuery.data],
+ );
+ const readiness = React.useMemo(
+ () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig),
+ [globalConfig, runtimesQuery.data],
+ );
+ React.useEffect(() => {
+ if (
+ !channelId ||
+ !isActiveWelcome ||
+ configLoading ||
+ runtimesQuery.isPending
+ ) {
+ return;
+ }
+
+ const kickoffController = kickoffCoordinator.begin(channelId);
+ if (!kickoffController) return;
+ const isCancelled = () =>
+ kickoffController.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId;
+ const landingBeat = waitForWelcomeKickoffBeat({
+ signal: kickoffController.signal,
+ });
+ void (async () => {
+ try {
+ const welcomeTeam = await ensureWelcomeTeam(
+ channelId,
+ activeCommunity?.relayUrl,
+ );
+ await queryClient.invalidateQueries({
+ queryKey: managedAgentsQueryKey,
+ });
+ const resolvedAgentSet: WelcomeAgentSet = {
+ lead: welcomeTeam[0],
+ teammates: [welcomeTeam[1], welcomeTeam[2]],
+ };
+
+ if (await markerExists(channelId, closerMarker)) {
+ return;
+ }
+ if (!readiness.ready) {
+ await sendManagedAgentChannelMessage({
+ agentPubkey: resolvedAgentSet.lead.pubkey,
+ channelId,
+ content: WELCOME_KICKOFF_PROVIDER_MESSAGE,
+ marker: providerMarker,
+ markerScope: "channel",
+ });
+ return;
+ }
+ const openerAlreadySent = await markerExists(channelId, openerMarker);
+
+ // Start before publishing the mention. buzz-acp replays events from its
+ // startup watermark, so no separate subscription-ready wait is needed.
+ // On resume, restart unresolved teammates but never replay the opener.
+ const agentsToStart = openerAlreadySent
+ ? resolvedAgentSet.teammates
+ : [resolvedAgentSet.lead, ...resolvedAgentSet.teammates];
+ const startResults = await Promise.allSettled(
+ agentsToStart.map((agent) => {
+ const isTeammate = resolvedAgentSet.teammates.some(
+ (teammate) =>
+ normalizePubkey(teammate.pubkey) ===
+ normalizePubkey(agent.pubkey),
+ );
+ if (
+ isTeammate &&
+ welcomeTeammateNeedsRestart(agent, resolvedAgentSet.lead.pubkey)
+ ) {
+ return restartWelcomeTeammate(agent);
+ }
+ return agent.status === "running" || agent.status === "deployed"
+ ? Promise.resolve(agent)
+ : startManagedAgent(agent.pubkey);
+ }),
+ );
+ for (const [index, result] of startResults.entries()) {
+ if (result.status === "rejected") {
+ console.warn(
+ `Failed to start Welcome agent ${agentsToStart[index]?.name ?? "unknown"}.`,
+ result.reason,
+ );
+ }
+ }
+ await queryClient.invalidateQueries({
+ queryKey: managedAgentsQueryKey,
+ });
+ if (openerAlreadySent) return;
+
+ const leadStartIndex = agentsToStart.findIndex(
+ (agent) => agent.pubkey === resolvedAgentSet.lead.pubkey,
+ );
+ if (startResults[leadStartIndex]?.status === "rejected") return;
+ const teammatesToAwait = resolvedAgentSet.teammates.filter(
+ (teammate) =>
+ startResults[
+ agentsToStart.findIndex(
+ (agent) => agent.pubkey === teammate.pubkey,
+ )
+ ]?.status !== "rejected",
+ );
+ const onlineTeammates = await waitForWelcomeTeammatesOnline(
+ teammatesToAwait,
+ { isCancelled },
+ );
+ if (isCancelled()) return;
+ const introTeammates = selectWelcomeKickoffIntroTeammates(
+ resolvedAgentSet.teammates,
+ onlineTeammates,
+ );
+ if (introTeammates.length < resolvedAgentSet.teammates.length) {
+ console.warn(
+ "Some Welcome teammates did not become ready; continuing with a degraded kickoff.",
+ );
+ }
+ if (!(await landingBeat) || isCancelled()) return;
+
+ const openerResult = await sendManagedAgentChannelMessage(
+ buildWelcomeKickoffOpenerSendInput(
+ resolvedAgentSet,
+ introTeammates,
+ channelId,
+ ),
+ );
+ if (!isCancelled()) onKickoffOpenerPosted?.(openerResult.eventId);
+ } catch (error) {
+ console.warn("Failed to start the Welcome team kickoff.", error);
+ } finally {
+ kickoffCoordinator.finish(channelId, kickoffController);
+ }
+ })();
+ }, [
+ activeCommunity?.relayUrl,
+ channelId,
+ configLoading,
+ isActiveWelcome,
+ onKickoffOpenerPosted,
+ queryClient,
+ readiness,
+ runtimesQuery.isPending,
+ ]);
+
+ React.useEffect(() => {
+ void isActiveWelcome;
+ return () => {
+ if (!channelId) return;
+ kickoffCoordinator.cancel(channelId);
+ closerAbortControllers.get(channelId)?.abort();
+ closerAbortControllers.delete(channelId);
+ const timeout = closerTimeouts.get(channelId);
+ if (timeout) globalThis.clearTimeout(timeout);
+ closerTimeouts.delete(channelId);
+ closerInFlight.delete(channelId);
+ };
+ }, [channelId, isActiveWelcome]);
+
+ React.useEffect(() => {
+ if (
+ !channelId ||
+ !isActiveWelcome ||
+ !agentSet ||
+ closerInFlight.has(channelId)
+ )
+ return;
+ const opener = markerEvent(channelEvents, openerMarker);
+ if (!opener || markerEvent(channelEvents, closerMarker)) {
+ return;
+ }
+
+ const { unresolved } = classifyWelcomeKickoffResolution(
+ channelEvents,
+ opener,
+ agentSet,
+ );
+
+ if (unresolved.length > 0) {
+ if (!closerTimeouts.has(channelId)) {
+ const elapsedMs = Math.max(0, Date.now() - opener.created_at * 1_000);
+ const waitMs = Math.max(0, TEAMMATE_INTRO_WAIT_MS - elapsedMs);
+ const timeout = globalThis.setTimeout(() => {
+ closerTimeouts.delete(channelId);
+ if (focusedWelcomeChannelRef.current !== channelId) return;
+ const controller = new AbortController();
+ closerAbortControllers.set(channelId, controller);
+ closerInFlight.add(channelId);
+ void (async () => {
+ if (
+ !(await waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: CLOSER_BEAT_MS,
+ })) ||
+ controller.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId
+ )
+ return;
+
+ const latestEvents = channelEventsRef.current;
+ if (markerEvent(latestEvents, closerMarker)) return;
+ const latestOpener =
+ markerEvent(latestEvents, openerMarker) ?? opener;
+ const latestAgentSet = await resolveLatestWelcomeAgentSet({
+ fallback: agentSet,
+ queryClient,
+ relayUrl: activeCommunity?.relayUrl,
+ });
+ const latestResolution = classifyWelcomeKickoffResolution(
+ latestEvents,
+ latestOpener,
+ latestAgentSet,
+ );
+ await sendWelcomeKickoffCloser({
+ agentSet: latestAgentSet,
+ channelId,
+ content: buildWelcomeKickoffCloser(
+ latestResolution.failed.map((agent) => agent.name),
+ latestResolution.unresolved.map((agent) => agent.name),
+ ),
+ opener: latestOpener,
+ });
+ })()
+ .catch((error) => {
+ console.warn("Failed to finish the Welcome team kickoff.", error);
+ })
+ .finally(() => {
+ if (closerAbortControllers.get(channelId) === controller) {
+ closerAbortControllers.delete(channelId);
+ }
+ closerInFlight.delete(channelId);
+ });
+ }, waitMs);
+ closerTimeouts.set(channelId, timeout);
+ }
+ return;
+ }
+
+ const timeout = closerTimeouts.get(channelId);
+ if (timeout) globalThis.clearTimeout(timeout);
+ closerTimeouts.delete(channelId);
+
+ const controller = new AbortController();
+ closerAbortControllers.set(channelId, controller);
+ closerInFlight.add(channelId);
+ void (async () => {
+ if (
+ !(await waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: CLOSER_BEAT_MS,
+ })) ||
+ controller.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId
+ )
+ return;
+
+ const latestEvents = channelEventsRef.current;
+ const latestOpener = markerEvent(latestEvents, openerMarker) ?? opener;
+ const latestAgentSet = await resolveLatestWelcomeAgentSet({
+ fallback: agentSet,
+ queryClient,
+ relayUrl: activeCommunity?.relayUrl,
+ });
+ const latestResolution = classifyWelcomeKickoffResolution(
+ latestEvents,
+ latestOpener,
+ latestAgentSet,
+ );
+ await sendWelcomeKickoffCloser({
+ agentSet: latestAgentSet,
+ channelId,
+ content: buildWelcomeKickoffCloser(
+ latestResolution.failed.map((agent) => agent.name),
+ ),
+ opener: latestOpener,
+ });
+ })()
+ .catch((error) => {
+ console.warn("Failed to finish the Welcome team kickoff.", error);
+ })
+ .finally(() => {
+ if (closerAbortControllers.get(channelId) === controller) {
+ closerAbortControllers.delete(channelId);
+ }
+ closerInFlight.delete(channelId);
+ });
+ }, [
+ activeCommunity?.relayUrl,
+ agentSet,
+ channelEvents,
+ channelId,
+ isActiveWelcome,
+ queryClient,
+ ]);
+}
+
+export type { WelcomeTeamStarterDefinition };
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index eee4c60524..6619b5b5b4 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -23,7 +23,10 @@ import {
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
-import { ownsAuthorAgent } from "@/features/profile/lib/identity";
+import {
+ formatOwnerLabel,
+ ownsAuthorAgent,
+} from "@/features/profile/lib/identity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
@@ -205,6 +208,11 @@ export function UserProfilePopover({
(a) => a.pubkey === pubkey,
);
const profile = profileQuery.data;
+ const ownerPubkey = profile?.ownerPubkey ?? null;
+ const ownerProfileQuery = useUsersBatchQuery(
+ ownerPubkey ? [ownerPubkey] : [],
+ { enabled: open && Boolean(ownerPubkey) },
+ );
const normalizedPubkey = normalizePubkey(pubkey);
const isAgentByOaOwner = Boolean(
usersBatchQuery.data?.profiles[normalizedPubkey]?.isAgent,
@@ -232,6 +240,13 @@ export function UserProfilePopover({
const isOwner = useIsManagedAgent(isBotProfile ? pubkey : null);
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
+ const ownerLabel = isBotProfile
+ ? formatOwnerLabel(
+ ownerPubkey,
+ currentPubkey,
+ ownerProfileQuery.data?.profiles,
+ )
+ : null;
const isSelf =
currentPubkey !== undefined &&
currentPubkey.toLowerCase() === pubkey.toLowerCase();
@@ -511,6 +526,14 @@ export function UserProfilePopover({
/>
) : null}
+ {isBotProfile && ownerLabel ? (
+
+ owned by {ownerLabel}
+
+ ) : null}
{profileSubheader ? (
{
+ toggle(value);
+ if (feature.id === "agentManagedProfiles") {
+ void setAgentManagedProfiles(value).catch((error) => {
+ console.error(
+ "Failed to apply agent-managed profiles setting:",
+ error,
+ );
+ });
+ }
+ }}
/>
);
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index d32864c555..bf6b6e8682 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -8,6 +8,7 @@ import {
resolveEnabled,
useFeatureSnapshot,
} from "@/shared/features/useFeatureEnabled";
+import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
@@ -23,6 +24,7 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/shared/ui/sidebar";
+import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
import {
renderSettingsSection,
settingsSections,
@@ -103,7 +105,7 @@ function SettingsSectionButton({
: "text-sidebar-foreground/70",
)}
/>
-
{section.label}
+
{section.label}
);
@@ -216,8 +218,13 @@ export function SettingsView({
data-testid="settings-sidebar"
variant="sidebar"
>
+
@@ -257,7 +264,11 @@ export function SettingsView({
{appVersion ? (
-
+
v{appVersion}
) : null}
@@ -269,15 +280,23 @@ export function SettingsView({
"isolate relative min-h-0 min-w-0 overflow-hidden bg-sidebar motion-safe:transition-opacity motion-safe:duration-200",
isLoaded ? "opacity-100" : "opacity-0",
)}
+ data-buzz-shadow-viewport
data-testid="settings-view"
>
-
-
+
+
@@ -582,105 +578,57 @@ export function AppSidebar({
) : null}
- {isLoading ? (
-
- ) : null}
+
+
- {!isLoading ? (
- <>
- {starredChannels.length > 0 ? (
-
- unreadChannelIds.has(c.id),
- )}
- isCollapsed={collapsedGroups.starred}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={starredChannels}
- sortMode={sortModeFor("starred")}
- onSortModeChange={(mode) => setSortModeFor("starred", mode)}
- actionsTestId="section-actions-starred"
- listTestId="starred-list"
- onMarkAllRead={() => {
- for (const channel of starredChannels) {
- onMarkChannelRead(channel.id, channel.lastMessageAt);
- }
- }}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("starred")}
- selectedChannelId={selectedChannelId}
- title="Starred"
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- mutedChannelIds={mutedChannelIds}
- onMuteChannel={onMuteChannel}
- onUnmuteChannel={onUnmuteChannel}
- starredChannelIds={starredChannelIds}
- onStarChannel={onStarChannel}
- onUnstarChannel={onUnstarChannel}
- onLeaveChannel={requestLeaveChannel}
- />
- ) : null}
-
- {channelSections.map((section, idx) => (
-
- unreadChannelIds.has(c.id),
- ) ?? false
- }
- isCollapsed={collapsedSections[section.id] ?? false}
+ {isLoading ? (
+
+ ) : null}
+
+ {!isLoading ? (
+ <>
+ {starredChannels.length > 0 ? (
+
+ unreadChannelIds.has(c.id),
+ )}
+ isCollapsed={collapsedGroups.starred}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
- selectedChannelId={selectedChannelId}
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- sections={channelSections}
- assignments={channelAssignments}
- isFirst={idx === 0}
- isLast={idx === channelSections.length - 1}
- sortMode={sortModeFor(sectionSortGroupKey(section.id))}
+ items={starredChannels}
+ sortMode={sortModeFor("starred")}
onSortModeChange={(mode) =>
- setSortModeFor(sectionSortGroupKey(section.id), mode)
+ setSortModeFor("starred", mode)
}
- onToggleCollapsed={() =>
- toggleCollapsedSection(section.id)
- }
- onSelectChannel={onSelectChannel}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onMarkSectionRead={() => {
- for (const channel of sectionBuckets.bySection[
- section.id
- ] ?? []) {
+ actionsTestId="section-actions-starred"
+ listTestId="starred-list"
+ onMarkAllRead={() => {
+ for (const channel of starredChannels) {
onMarkChannelRead(channel.id, channel.lastMessageAt);
}
}}
- onAssignChannel={assignChannel}
- onUnassignChannel={unassignChannel}
- onCreateSectionForChannel={handleCreateSectionForChannel}
- onCreateChannel={() =>
- handleCreateChannelInSection(section.id)
- }
- onRenameSection={() => setRenameSectionTarget(section)}
- onDeleteSection={() => setDeleteSectionTarget(section)}
- onMoveSectionUp={() => moveSectionUp(section.id)}
- onMoveSectionDown={() => moveSectionDown(section.id)}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("starred")}
+ selectedChannelId={selectedChannelId}
+ title="Starred"
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
@@ -689,123 +637,197 @@ export function AppSidebar({
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
- ))}
- 0}
- isCollapsed={collapsedGroups.channels}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={sectionBuckets.unassigned}
- sortMode={sortModeFor("channels")}
- onSortModeChange={(mode) =>
- setSortModeFor("channels", mode)
- }
- actionsTestId="section-actions-channels"
- listTestId="stream-list"
- quickCreateLabel="Add channel"
- onQuickCreateClick={onBrowseChannels}
- showQuickCreate
- onMarkAllRead={onMarkAllChannelsRead}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("channels")}
- selectedChannelId={selectedChannelId}
- title="Channels"
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
+ ) : null}
+
-
-
- 0}
- isCollapsed={collapsedGroups.forums}
+ onReorderSections={reorderSections}
+ >
+ {channelSections.map((section, idx) => (
+
+ unreadChannelIds.has(c.id),
+ ) ?? false
+ }
+ isCollapsed={collapsedSections[section.id] ?? false}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ selectedChannelId={selectedChannelId}
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ sections={channelSections}
+ assignments={channelAssignments}
+ isFirst={idx === 0}
+ isLast={idx === channelSections.length - 1}
+ sortMode={sortModeFor(sectionSortGroupKey(section.id))}
+ onSortModeChange={(mode) =>
+ setSortModeFor(sectionSortGroupKey(section.id), mode)
+ }
+ onToggleCollapsed={() =>
+ toggleCollapsedSection(section.id)
+ }
+ onSelectChannel={onSelectChannel}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onMarkSectionRead={() => {
+ for (const channel of sectionBuckets.bySection[
+ section.id
+ ] ?? []) {
+ onMarkChannelRead(
+ channel.id,
+ channel.lastMessageAt,
+ );
+ }
+ }}
+ onAssignChannel={assignChannel}
+ onUnassignChannel={unassignChannel}
+ onCreateSectionForChannel={
+ handleCreateSectionForChannel
+ }
+ onCreateChannel={() =>
+ handleCreateChannelInSection(section.id)
+ }
+ onRenameSection={() => setRenameSectionTarget(section)}
+ onDeleteSection={() => setDeleteSectionTarget(section)}
+ onMoveSectionUp={() => moveSectionUp(section.id)}
+ onMoveSectionDown={() => moveSectionDown(section.id)}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ starredChannelIds={starredChannelIds}
+ onStarChannel={onStarChannel}
+ onUnstarChannel={onUnstarChannel}
+ onLeaveChannel={requestLeaveChannel}
+ />
+ ))}
+ 0}
+ isCollapsed={collapsedGroups.channels}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ items={sectionBuckets.unassigned}
+ sortMode={sortModeFor("channels")}
+ onSortModeChange={(mode) =>
+ setSortModeFor("channels", mode)
+ }
+ actionsTestId="section-actions-channels"
+ listTestId="stream-list"
+ quickCreateLabel="Add channel"
+ onQuickCreateClick={onBrowseChannels}
+ showQuickCreate
+ onMarkAllRead={onMarkAllChannelsRead}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("channels")}
+ selectedChannelId={selectedChannelId}
+ title="Channels"
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ sections={channelSections}
+ assignments={channelAssignments}
+ onAssignChannel={assignChannel}
+ onUnassignChannel={unassignChannel}
+ onCreateSectionForChannel={handleCreateSectionForChannel}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ starredChannelIds={starredChannelIds}
+ onStarChannel={onStarChannel}
+ onUnstarChannel={onUnstarChannel}
+ onLeaveChannel={requestLeaveChannel}
+ />
+
+
+ 0}
+ isCollapsed={collapsedGroups.forums}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ items={forumChannels}
+ sortMode={sortModeFor("forums")}
+ onSortModeChange={(mode) =>
+ setSortModeFor("forums", mode)
+ }
+ actionsTestId="section-actions-forums"
+ listTestId="forum-list"
+ onCreateClick={() => openCreateDialog("forum")}
+ onMarkAllRead={onMarkAllChannelsRead}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("forums")}
+ selectedChannelId={selectedChannelId}
+ title="Forums"
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ />
+
+
+
+
+ setSortModeFor("dms", mode)
+ }
+ />
+
+ }
+ dmParticipantsByChannelId={dmParticipantsByChannelId}
+ isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
- items={forumChannels}
- sortMode={sortModeFor("forums")}
- onSortModeChange={(mode) => setSortModeFor("forums", mode)}
- actionsTestId="section-actions-forums"
- listTestId="forum-list"
- onCreateClick={() => openCreateDialog("forum")}
- onMarkAllRead={onMarkAllChannelsRead}
+ items={sortedDirectMessages}
+ channelLabels={dmChannelLabels}
+ onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("forums")}
+ onToggleCollapsed={() =>
+ toggleCollapsedGroup("directMessages")
+ }
+ presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
- title="Forums"
+ testId="dm-list"
+ title="Direct messages"
+ sectionActionsOpen={dmActionsMenuOpen}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
-
-
-
- setSortModeFor("dms", mode)}
- />
-
- }
- dmParticipantsByChannelId={dmParticipantsByChannelId}
- isCollapsed={collapsedGroups.directMessages}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={sortedDirectMessages}
- channelLabels={dmChannelLabels}
- onHideDm={onHideDm}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() =>
- toggleCollapsedGroup("directMessages")
- }
- presenceByChannelId={dmPresenceByChannelId}
- selectedChannelId={selectedChannelId}
- testId="dm-list"
- title="Direct messages"
- sectionActionsOpen={dmActionsMenuOpen}
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- mutedChannelIds={mutedChannelIds}
- onMuteChannel={onMuteChannel}
- onUnmuteChannel={onUnmuteChannel}
- />
- >
- ) : null}
+ >
+ ) : null}
- {errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
-
- {errorMessage}
-
- ) : null}
+ {errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
+
+ {errorMessage}
+
+ ) : null}
+
diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
index 79249dfa90..a2031196bc 100644
--- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
@@ -10,6 +10,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
+import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
type SidebarSelectedView =
| "home"
@@ -23,44 +24,43 @@ type SidebarSelectedView =
type AppSidebarPinnedHeaderProps = {
channelLabels: Record;
currentPubkey?: string;
- homeBadgeCount: number;
onCreateAgent: () => void;
onCreateChannel: () => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise;
onOpenSearchResult: (hit: SearchHit) => void;
- onSelectAgents: () => void;
onSelectChannel: (channelId: string) => void;
+ searchChannels: Channel[];
+ searchFocusRequest: number;
+ suggestionChannels: Channel[];
+};
+
+type AppSidebarPrimaryMenuProps = {
+ homeBadgeCount: number;
+ onSelectAgents: () => void;
onSelectHome: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
- searchChannels: Channel[];
- searchFocusRequest: number;
selectedView: SidebarSelectedView;
- suggestionChannels: Channel[];
};
export function AppSidebarPinnedHeader({
channelLabels,
currentPubkey,
- homeBadgeCount,
onCreateAgent,
onCreateChannel,
onOpenDm,
onOpenSearchResult,
- onSelectAgents,
onSelectChannel,
- onSelectHome,
- onSelectProjects,
- onSelectPulse,
- onSelectWorkflows,
searchChannels,
searchFocusRequest,
- selectedView,
suggestionChannels,
}: AppSidebarPinnedHeaderProps) {
return (
-
+
-
-
-
-
-
- Inbox
-
- {homeBadgeCount > 0 ? (
-
- {Math.min(homeBadgeCount, 99)}
-
- ) : null}
-
-
-
-
-
- Pulse
-
-
-
-
-
-
-
- Projects
-
-
-
-
-
-
- Agents
-
-
-
-
-
-
- Workflows
-
-
-
-
-
);
}
+
+export function AppSidebarPrimaryMenu({
+ homeBadgeCount,
+ onSelectAgents,
+ onSelectHome,
+ onSelectProjects,
+ onSelectPulse,
+ onSelectWorkflows,
+ selectedView,
+}: AppSidebarPrimaryMenuProps) {
+ return (
+
+
+
+
+
+ Inbox
+
+ {homeBadgeCount > 0 ? (
+
+ {Math.min(homeBadgeCount, 99)}
+
+ ) : null}
+
+
+
+
+
+ Pulse
+
+
+
+
+
+
+
+ Projects
+
+
+
+
+
+
+ Agents
+
+
+
+
+
+
+ Workflows
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
index be7eba8cc0..2b266e887e 100644
--- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
+++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
@@ -293,12 +293,14 @@ function ChannelSectionHeader({
isCollapsed,
onToggleCollapsed,
title,
+ testId,
actions,
}: {
contentId: string;
isCollapsed: boolean;
onToggleCollapsed: () => void;
title: string;
+ testId: string;
actions: React.ReactNode;
}) {
return (
@@ -308,10 +310,11 @@ function ChannelSectionHeader({
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
+ data-testid={`${testId}-section-label`}
onClick={onToggleCollapsed}
type="button"
>
-
{title}
+
{title}
{showQuickCreate && (onQuickCreateClick ?? onCreateClick) ? (
@@ -626,11 +630,17 @@ export function CustomChannelSection({
-
+
@@ -648,6 +658,7 @@ export function CustomChannelSection({
) : null}
{section.name}
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
index 467b2fae7f..a714d5e58a 100644
--- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
@@ -78,7 +78,10 @@ export function SidebarProfileCard({
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
const communityLabel = activeCommunity?.name ?? "No community";
const readonlyCommunityLabel = (
-
+
{
event.stopPropagation();
@@ -211,6 +215,7 @@ export function SidebarProfileCard({
"pointer-events-none absolute inset-0 flex min-w-0 items-center text-xs leading-snug text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100",
profilePopoverOpen && "opacity-0",
)}
+ data-buzz-sidebar-secondary
>
{readonlyCommunityLabel}
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx
index 84b4a7a27b..3e39c60a6d 100644
--- a/desktop/src/features/sidebar/ui/SidebarSection.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx
@@ -294,7 +294,9 @@ export function ChannelMenuButton({
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
- {resolvedLabel}
+
+ {resolvedLabel}
+
{ephemeralDisplay ? (
- {title}
+ {title}
{
});
return {
content: response.content,
- updatedAt: response.updated_at,
- author: response.author,
+ // Normalize absent keys to null: ensureWelcomeCanvas treats null as
+ // "no canvas yet", and `undefined !== null` would make every fresh
+ // channel look already-seeded.
+ updatedAt: response.updated_at ?? null,
+ author: response.author ?? null,
};
}
@@ -638,6 +642,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
pubkey: agent.pubkey,
name: agent.name,
personaId: agent.persona_id,
+ teamId: agent.team_id ?? null,
relayUrl: agent.relay_url,
acpCommand: agent.acp_command,
agentCommand: agent.agent_command,
@@ -651,7 +656,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
systemPrompt: agent.system_prompt,
avatarUrl: agent.avatar_url ?? null,
model: agent.model,
- // Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
provider: agent.provider ?? null,
personaOutOfDate: agent.persona_out_of_date ?? false,
personaOrphaned: agent.persona_orphaned ?? false,
@@ -1063,12 +1067,14 @@ export async function applyCommunity(
nsec?: string,
token?: string,
reposDir?: string,
+ agentManagedProfiles?: boolean,
): Promise {
await invokeTauri("apply_workspace", {
relayUrl,
nsec: nsec ?? null,
token: token ?? null,
reposDir: reposDir ?? null,
+ agentManagedProfiles: agentManagedProfiles ?? false,
});
}
@@ -1081,6 +1087,9 @@ export async function validateReposDir(dir: string): Promise {
export const setPreventSleepActive = (active: boolean) =>
invokeTauri("set_prevent_sleep_active", { active });
+export const setAgentManagedProfiles = (enabled: boolean) =>
+ invokeTauri("set_agent_managed_profiles", { enabled });
+
/** Returns true on macOS, Windows, and Linux AppImage installs.
* Returns false on Linux non-AppImage packages (e.g. .deb) where
* Tauri's updater cannot swap the binary. */
diff --git a/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
new file mode 100644
index 0000000000..2582e752f2
--- /dev/null
+++ b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
@@ -0,0 +1,15 @@
+import { invokeTauri } from "@/shared/api/tauri";
+
+export async function hasManagedAgentChannelMessageMarker(input: {
+ channelId: string;
+ marker: string;
+ agentPubkey?: string;
+ markerScope?: "agent" | "channel";
+}): Promise {
+ return invokeTauri("has_managed_agent_channel_message_marker", {
+ channelId: input.channelId,
+ marker: input.marker,
+ agentPubkey: input.agentPubkey ?? null,
+ markerScope: input.markerScope ?? null,
+ });
+}
diff --git a/desktop/src/shared/api/tauriManagedAgentMessages.ts b/desktop/src/shared/api/tauriManagedAgentMessages.ts
index b7a6b2114a..4e01b87c84 100644
--- a/desktop/src/shared/api/tauriManagedAgentMessages.ts
+++ b/desktop/src/shared/api/tauriManagedAgentMessages.ts
@@ -15,6 +15,9 @@ export async function sendManagedAgentChannelMessage(input: {
content: string;
marker?: string;
markerScope?: "agent" | "channel";
+ mentionPubkeys?: string[];
+ parentEventId?: string;
+ additionalMarkers?: string[];
}): Promise {
const response = await invokeTauri(
"send_managed_agent_channel_message",
@@ -24,6 +27,9 @@ export async function sendManagedAgentChannelMessage(input: {
content: input.content,
marker: input.marker ?? null,
markerScope: input.markerScope ?? null,
+ mentionPubkeys: input.mentionPubkeys ?? null,
+ parentEventId: input.parentEventId ?? null,
+ additionalMarkers: input.additionalMarkers ?? null,
},
);
diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts
index 24ee02d277..03f4701e34 100644
--- a/desktop/src/shared/api/tauriMesh.ts
+++ b/desktop/src/shared/api/tauriMesh.ts
@@ -68,6 +68,11 @@ export type MeshCatalogEntry = {
fit: MeshModelFit;
installed: boolean;
recommended: boolean;
+ /**
+ * Buzz-curated pick — known to survive the agent harness. Curated entries
+ * render above the fold; everything else is "advanced".
+ */
+ curated: boolean;
};
export type MeshModelCatalog = {
@@ -75,7 +80,7 @@ export type MeshModelCatalog = {
vramDisplay: string;
vramGb: number;
recommended: string | null;
- /** Ranked: recommended first, then by fit, then larger first within a fit. */
+ /** Ranked: recommended first, then curated, then by fit, larger first. */
entries: MeshCatalogEntry[];
};
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index b82fa06d3a..fdd355d88b 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -319,6 +319,7 @@ export type ManagedAgent = {
pubkey: string;
name: string;
personaId: string | null;
+ teamId?: string | null;
relayUrl: string;
acpCommand: string;
/** Resolved/effective harness command (persona-wins, override-honored). */
diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs
index 36a357ebae..5c02813720 100644
--- a/desktop/src/shared/lib/mediaUrl.test.mjs
+++ b/desktop/src/shared/lib/mediaUrl.test.mjs
@@ -12,6 +12,14 @@ import {
const HASH = "a".repeat(64);
+function deferred() {
+ let resolve;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () => {
assert.equal(
mediaProxyUrl(54321, `${HASH}.png`),
@@ -191,3 +199,105 @@ test("withDeadline: a rejection after the deadline is observed, not unhandled",
process.off("unhandledRejection", onUnhandled);
}
});
+
+test("resetMediaCaches: ignores relay origin lookups from the previous generation", async () => {
+ const previousWindow = globalThis.window;
+ const staleOrigin = deferred();
+ let relayOriginCalls = 0;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ relayOriginCalls += 1;
+ return relayOriginCalls === 1
+ ? staleOrigin.promise
+ : Promise.resolve("https://active.example");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ // A unique URL triggers module-load fetching with the stale relay lookup
+ // still unresolved, matching a cold launch before applyCommunity finishes.
+ const mediaUrl = await import(`./mediaUrl.ts?race=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ mediaUrl.resetMediaCaches();
+ const activeUrl = `https://active.example/media/${HASH}.png`;
+ mediaUrl.rewriteRelayUrl(activeUrl);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // Complete the old lookup after reset. It must not overwrite the active
+ // community origin fetched by the new generation.
+ staleOrigin.resolve("https://stale.example");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(
+ mediaUrl.rewriteRelayUrl(activeUrl),
+ `http://127.0.0.1:54321/media/${HASH}.png`,
+ );
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
+
+test("rewriteRelayUrl: matches relay origin case-insensitively (uppercase saved community URL)", async () => {
+ const previousWindow = globalThis.window;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ // Saved community URLs keep the user's casing; the relay always
+ // emits lowercased media URLs (normalize_host in buzz-core).
+ return Promise.resolve("https://PENDING-SEED.communities.buzz.xyz");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ const mediaUrl = await import(`./mediaUrl.ts?case=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const relayMediaUrl = `https://pending-seed.communities.buzz.xyz/media/${HASH}.png`;
+ assert.equal(
+ mediaUrl.rewriteRelayUrl(relayMediaUrl),
+ `http://127.0.0.1:54321/media/${HASH}.png`,
+ );
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
+
+test("rewriteRelayUrl: still passes external Blossom URLs through unchanged", async () => {
+ const previousWindow = globalThis.window;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ return Promise.resolve("https://relay.example");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ const mediaUrl = await import(`./mediaUrl.ts?external=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const externalUrl = `https://nostr.build/media/${HASH}.png`;
+ assert.equal(mediaUrl.rewriteRelayUrl(externalUrl), externalUrl);
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts
index 6503d75dd5..7ce58bba61 100644
--- a/desktop/src/shared/lib/mediaUrl.ts
+++ b/desktop/src/shared/lib/mediaUrl.ts
@@ -26,16 +26,39 @@ const RELAY_MEDIA_RE =
let cachedPort: number | null = null;
let portPromise: Promise | null = null;
-/** Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"). */
+/**
+ * Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"),
+ * canonicalized via {@link canonicalOrigin} so comparisons are stable.
+ */
let cachedRelayOrigin: string | null = null;
/**
- * Generation token for the relay-origin fetch. Bumped on every
- * `resetMediaCaches` (i.e. workspace switch) so an origin fetch started for
- * the previous community can never publish its stale origin after the switch:
- * only a resolution whose generation still matches the current one is applied.
+ * Canonicalize a URL to its origin with a lowercased scheme/host.
+ *
+ * The relay always emits media URLs with a lowercased tenant host
+ * (`normalize_host` in buzz-core), but the saved community relay URL keeps
+ * whatever casing the user typed (DNS is case-insensitive, so an uppercase
+ * host connects fine). A raw string comparison between the two misclassifies
+ * the relay's own media URLs as external and skips the authenticated proxy.
+ * `new URL().origin` lowercases scheme + host and drops default ports.
+ *
+ * Returns null for unparseable input.
*/
-let relayOriginGeneration = 0;
+function canonicalOrigin(url: string): string | null {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Monotonic cache generation, bumped on every `resetMediaCaches` (i.e.
+ * workspace switch). Async lookups capture the current generation and may
+ * only publish results while it is still current, so a lookup started for the
+ * previous community can never repopulate caches (port or origin) after reset.
+ */
+let cacheGeneration = 0;
/** `useSyncExternalStore` listeners for relay-origin changes. */
const relayOriginListeners = new Set<() => void>();
@@ -50,7 +73,7 @@ function notifyRelayOriginListeners(): void {
* only on an actual snapshot change so `useSyncExternalStore` doesn't churn.
*/
function setRelayOrigin(origin: string | null, generation: number): void {
- if (generation !== relayOriginGeneration) return;
+ if (generation !== cacheGeneration) return;
if (cachedRelayOrigin === origin) return;
cachedRelayOrigin = origin;
notifyRelayOriginListeners();
@@ -64,7 +87,7 @@ function setRelayOrigin(origin: string | null, generation: number): void {
* community B. Callers invoke the returned function once the origin resolves.
*/
export function beginRelayOriginFetch(): (origin: string | null) => void {
- const generation = relayOriginGeneration;
+ const generation = cacheGeneration;
return (origin) => setRelayOrigin(origin, generation);
}
@@ -133,9 +156,13 @@ async function fetchProxyPort(): Promise {
// fire-and-forget attempt that fails before the bridge is up would leave the
// origin unresolved forever, hiding relay Download eligibility. The publisher
// captures the generation at each attempt so a resolution that lands after a
- // workspace switch is discarded rather than publishing a stale origin.
+ // workspace switch is discarded rather than publishing a stale origin. The
+ // loop itself also captures the generation so a still-running poll from the
+ // previous community can neither keep spinning nor cache its stale port
+ // after a reset.
+ const generation = cacheGeneration;
const deadline = Date.now() + POLL_TIMEOUT_MS;
- while (Date.now() < deadline) {
+ while (Date.now() < deadline && generation === cacheGeneration) {
if (!cachedRelayOrigin) {
const publishRelayOrigin = beginRelayOriginFetch();
try {
@@ -143,7 +170,7 @@ async function fetchProxyPort(): Promise {
invoke("get_relay_http_url"),
deadline,
);
- if (url !== null) publishRelayOrigin(url.replace(/\/+$/, ""));
+ if (url !== null) publishRelayOrigin(canonicalOrigin(url));
} catch {
// invoke failed (e.g. Tauri IPC not ready yet) — keep retrying
}
@@ -155,7 +182,9 @@ async function fetchProxyPort(): Promise {
invoke("get_media_proxy_port"),
deadline,
);
- if (port !== null && port > 0) cachedPort = port;
+ if (port !== null && port > 0 && generation === cacheGeneration) {
+ cachedPort = port;
+ }
} catch {
// invoke failed (e.g. Tauri IPC not ready yet) — keep retrying
}
@@ -192,9 +221,9 @@ if (typeof window !== "undefined") {
* listeners).
*/
export function resetMediaCaches(): void {
+ cacheGeneration += 1;
cachedPort = null;
portPromise = null;
- relayOriginGeneration += 1;
if (cachedRelayOrigin !== null) {
cachedRelayOrigin = null;
notifyRelayOriginListeners();
@@ -236,8 +265,14 @@ export function rewriteRelayUrl(url: string): string {
// (different origin) pass through unchanged — they work fine via WKWebView.
// If the relay origin isn't cached yet, fall through to the rewrite path
// as a safe default (relay URLs need the proxy to avoid Cloudflare 403s).
- if (cachedRelayOrigin && !url.startsWith(`${cachedRelayOrigin}/`)) {
- return url;
+ // Compare canonicalized origins: hosts are case-insensitive, and the relay
+ // always returns lowercased media URLs even when the saved community URL
+ // was typed with uppercase (e.g. wss://PENDING-SEED.communities.buzz.xyz).
+ if (cachedRelayOrigin) {
+ const urlOrigin = canonicalOrigin(url);
+ if (urlOrigin !== cachedRelayOrigin) {
+ return url;
+ }
}
if (cachedPort && cachedPort > 0) {
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index fd82e04c6b..fd60621933 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./globals/scrollbars.css";
+@import "./globals/motion.css";
@import "./globals/animations.css";
@import "./globals/composer.css";
@import "./globals/markdown.css";
diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css
index 86b44855c7..fa6dd786b8 100644
--- a/desktop/src/shared/styles/globals/components.css
+++ b/desktop/src/shared/styles/globals/components.css
@@ -215,15 +215,46 @@
}
.buzz-onboarding-neutral-theme.buzz-startup-shell {
- background-image: linear-gradient(to bottom, #d7d72e, #d7e7f6);
+ /* Dot grid over the chartreuse→light-blue gradient (dots layer first so
+ they sit on top). The welcome page overrides this with dots over a flat
+ chartreuse fill below. */
+ background-image:
+ radial-gradient(circle, rgb(0 0 0 / 0.08) 1px, transparent 1px),
+ linear-gradient(
+ to bottom,
+ var(--buzz-welcome-chartreuse),
+ var(--buzz-onboarding-shell-bottom)
+ );
+ background-size:
+ 24px 24px,
+ auto;
}
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-welcome {
- background-color: #d7d72e;
- background-image: none;
+ background-color: var(--buzz-welcome-chartreuse);
+ /* Landing is a flat chartreuse field, so the docked-footer scrim must match
+ it (not the gradient's light-blue bottom) to stay invisible. */
+ --buzz-onboarding-shell-bottom: var(--buzz-welcome-chartreuse);
+ /* Subtle dot grid: 2px dots spaced on a 24px lattice. */
+ background-image: radial-gradient(
+ circle,
+ rgb(0 0 0 / 0.08) 1px,
+ transparent 1px
+ );
+ background-size: 24px 24px;
}
.buzz-onboarding-neutral-theme {
+ /* Landing "chartreuse" — the welcome background; reused for the CTA label. */
+ --buzz-welcome-chartreuse: #d7d72e;
+ /* Bottom stop of the onboarding shell gradient; also the docked-footer
+ scrim color so content scrolling under the CTA fades into the page. */
+ --buzz-onboarding-shell-bottom: #d7e7f6;
+ /* Label color for the primary onboarding CTA (light blue on the dark pill). */
+ --buzz-onboarding-cta-label: #d7e6f0;
+ /* Olive ink for the backup step's private key and its "never share" caption,
+ reading against the fuzzy-white key frame. */
+ --buzz-onboarding-backup-ink: #717106;
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--primary: 0 0% 9%;
@@ -328,8 +359,13 @@
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
+ /* `backwards` (not `both`): the reveal ends on an identity transform, so
+ holding it forward is visually a no-op but leaves a lingering transform
+ that establishes a containing block and traps `position: fixed`
+ descendants (the bottom-docked onboarding footer). Reverting to no
+ transform at rest looks identical and frees fixed positioning. */
animation: buzz-onboarding-mask-reveal-up 760ms
- cubic-bezier(0.22, 1, 0.36, 1) both;
+ cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}
diff --git a/desktop/src/shared/styles/globals/motion.css b/desktop/src/shared/styles/globals/motion.css
new file mode 100644
index 0000000000..902f2f5be3
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.css
@@ -0,0 +1,57 @@
+/*
+ * Buzz motion system
+ *
+ * Keep shared timing, easing, distance, and semantic motion primitives here.
+ * Features decide when motion runs; this layer defines how Buzz moves.
+ */
+
+:root {
+ /* Durations: feedback → state change → composed arrival. */
+ --motion-duration-instant: 120ms;
+ --motion-duration-fast: 180ms;
+ --motion-duration-standard: 240ms;
+ --motion-duration-arrival: 500ms;
+
+ /* Easing: direct for feedback, confident deceleration for entrances. */
+ --motion-ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
+ --motion-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
+
+ /* Distances stay restrained so motion supports hierarchy without spectacle. */
+ --motion-distance-arrival: 0.75rem;
+ --motion-blur-arrival: 2px;
+}
+
+/* A newly-created conversational object settling into the timeline. */
+.motion-enter-conversation {
+ animation: motion-enter-conversation var(--motion-duration-arrival)
+ var(--motion-ease-arrival) both;
+}
+
+@keyframes motion-enter-conversation {
+ from {
+ filter: blur(var(--motion-blur-arrival));
+ opacity: 0;
+ transform: translateY(var(--motion-distance-arrival));
+ }
+
+ to {
+ filter: blur(0);
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .motion-enter-conversation {
+ animation-duration: 1ms;
+ }
+
+ @keyframes motion-enter-conversation {
+ from,
+ to {
+ filter: none;
+ opacity: 1;
+ transform: none;
+ }
+ }
+}
diff --git a/desktop/src/shared/styles/globals/motion.test.mjs b/desktop/src/shared/styles/globals/motion.test.mjs
new file mode 100644
index 0000000000..e47e9b89a8
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.test.mjs
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const motionCss = readFileSync(
+ new URL("./motion.css", import.meta.url),
+ "utf8",
+);
+
+test("conversation arrival uses shared motion tokens", () => {
+ assert.match(motionCss, /--motion-duration-arrival:\s*500ms/);
+ assert.match(motionCss, /--motion-ease-arrival:/);
+ assert.match(
+ motionCss,
+ /\.motion-enter-conversation\s*\{[\s\S]*var\(--motion-duration-arrival\)[\s\S]*var\(--motion-ease-arrival\)/,
+ );
+});
+
+test("conversation arrival has a reduced-motion treatment", () => {
+ assert.match(
+ motionCss,
+ /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.motion-enter-conversation/,
+ );
+});
diff --git a/desktop/src/shared/styles/globals/scrollbars.css b/desktop/src/shared/styles/globals/scrollbars.css
index a2b5ed6e5f..8a19fa560e 100644
--- a/desktop/src/shared/styles/globals/scrollbars.css
+++ b/desktop/src/shared/styles/globals/scrollbars.css
@@ -3,7 +3,11 @@
}
.buzz-sidebar-scrollbar:hover {
- scrollbar-color: hsl(var(--sidebar-border) / 0.8) transparent;
+ scrollbar-color: var(
+ --buzz-sidebar-scrollbar-thumb,
+ hsl(var(--sidebar-border) / 0.8)
+ )
+ transparent;
}
.buzz-sidebar-scrollbar::-webkit-scrollbar {
@@ -23,7 +27,10 @@
}
.buzz-sidebar-scrollbar:hover::-webkit-scrollbar-thumb {
- background-color: hsl(var(--sidebar-border) / 0.8);
+ background-color: var(
+ --buzz-sidebar-scrollbar-thumb,
+ hsl(var(--sidebar-border) / 0.8)
+ );
}
/*
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index 978e33b5dc..9add81c03f 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -47,6 +47,12 @@
--sidebar-accent-foreground: 234 16.02% 35.49%;
--sidebar-border: 225 13.56% 76.86%;
--sidebar-ring: 225 13.56% 76.86%;
+ /* Exact Buzz palette tokens; components consume these semantically. */
+ --buzz-gradient-light-top: #e6e6b6;
+ --buzz-gradient-light-bottom: #c4d0da;
+ --buzz-gradient-dark-top: #4a4616;
+ --buzz-gradient-dark-bottom: #0a1423;
+ --buzz-content-dark: 0 0% 10.1960784314%;
}
.dark {
@@ -176,60 +182,144 @@
* lives in Tailwind's `utilities` layer, and unlayered declarations win over
* any layered ones, so this reliably overrides it without an `!important`.
*
- * The gradient is applied to the shell wrapper plus every `bg-sidebar` canvas
- * surface inside it (top chrome, sidebar column, and the inset margin behind
- * the white content card). A few panes that do not literally carry
- * `bg-sidebar` are marked explicitly (`data-buzz-glass-inset` /
- * `data-buzz-glass-footer-wrap`) so they join the same visual layer. Two
- * chrome surfaces rendered outside the wrapper also get precise selectors: the
- * portaled mobile sidebar (`[data-sidebar="sidebar"][data-mobile="true"]`) and
- * the community rail (`[data-testid="community-rail"]`).
- * Scoping this way keeps the branding on the app chrome and off unrelated
- * `bg-sidebar` consumers rendered in portals outside the shell (e.g. the
- * persona catalog dialog). `background-attachment: fixed` anchors the gradient
- * to the viewport so these separate surfaces read as one continuous gradient
- * rather than each restarting the ramp. `background-color` is cleared so the
- * grey `--sidebar-background` never shows through.
+ * The gradient is painted once on the full app surface. Child chrome canvases
+ * stay transparent so the sidebar, rail, top chrome, inset margin, and Settings
+ * all reveal the same continuous ramp. Keeping one non-fixed background avoids
+ * WKWebView's stale fixed-background raster when the app changes appearance
+ * without a page reload. The portaled mobile sidebar is the sole separate
+ * paint because it lives outside the app surface.
*
* Small transient `bg-sidebar` chips inside the shell that are NOT canvas
* surfaces (the drag-overlay pills) opt out via `[data-buzz-flat]` - a
- * viewport-fixed gradient behind a small floating chip would show an arbitrary
- * slice of the ramp.
+ * floating chip should retain its own fill rather than becoming transparent
+ * over the full-app gradient.
*
* Item hover/active states use `bg-sidebar-accent` / `bg-sidebar-active`
* (distinct tokens) and are intentionally left untouched.
*/
:root[data-buzz-sidebar] {
- --buzz-gradient-top: #e6e6b6;
- --buzz-gradient-bottom: #c4d0da;
+ --buzz-gradient-top: var(--buzz-gradient-light-top);
+ --buzz-gradient-bottom: var(--buzz-gradient-light-bottom);
+ --buzz-muted-foreground: rgb(0 0 0 / 40%);
+ --buzz-search-surface: rgb(0 0 0 / 4%);
+ --buzz-chrome-foreground: rgb(0 0 0 / 50%);
--buzz-channel-fg: inherit;
--buzz-dm-fg: inherit;
--buzz-nav-fg: inherit;
- --buzz-hover-fill: #ffffff;
- --buzz-hover-alpha: 48%;
- --buzz-hover-surface: color-mix(
- in srgb,
- var(--buzz-hover-fill) var(--buzz-hover-alpha),
- transparent
- );
- --buzz-active-fill: 0 0% 100%;
+ --buzz-hover-surface: rgb(0 0 0 / 4%);
+ --buzz-sidebar-scrollbar-thumb: var(--buzz-hover-surface);
+ --buzz-active-fill: 0 0% 0%;
+ --buzz-active-surface: rgb(0 0 0 / 7%);
--buzz-active-foreground: var(--foreground);
}
/*
* Dark variant (Buzz Dark). Reuses the GitHub Dark base palette; only the
- * three Buzz tokens change, so every downstream rule (gradient fill, hover,
- * search box, active pill) adapts automatically. The `.dark` class is set on
- * the document root whenever a dark theme is active.
+ * Buzz color tokens change, so every downstream rule (gradient fill, hover,
+ * search box, muted labels, active pill) adapts automatically. The `.dark`
+ * class is set on the document root whenever a dark theme is active.
*/
:root[data-buzz-sidebar].dark {
- --buzz-gradient-top: #2b2b18;
- --buzz-gradient-bottom: #1b2530;
- --buzz-hover-alpha: 9.6%;
+ --buzz-gradient-top: var(--buzz-gradient-dark-top);
+ --buzz-gradient-bottom: var(--buzz-gradient-dark-bottom);
+ --buzz-muted-foreground: rgb(255 255 255 / 40%);
+ --buzz-search-surface: rgb(255 255 255 / 4%);
+ --buzz-chrome-foreground: rgb(255 255 255 / 50%);
+ --buzz-hover-surface: rgb(255 255 255 / 4%);
--buzz-active-fill: 0 0% 100%;
+ --buzz-active-surface: color-mix(
+ in srgb,
+ hsl(var(--buzz-active-fill)) 16%,
+ transparent
+ );
--buzz-active-foreground: 0 0% 100%;
}
+/* Keep the subtle content-card lift in Buzz Light only. */
+:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
+ box-shadow:
+ -1px -1px 0 0 hsl(var(--sidebar-border) / 0.45),
+ 0 0 4px 0 rgb(0 0 0 / 7%);
+}
+
+/* Let the content card's shadow feather into the surrounding gradient. */
+:root[data-buzz-sidebar]:not(.dark) [data-buzz-shadow-viewport] {
+ overflow: visible;
+}
+
+/*
+ * Buzz Dark uses a warmer neutral for the inset main/settings content canvas.
+ * Override `--background` locally as well as the surface fill so descendant
+ * headers and panes using `bg-background` remain visually continuous.
+ */
+:root[data-buzz-sidebar].dark [data-buzz-content-surface] {
+ --background: var(--buzz-content-dark);
+ background-color: hsl(var(--background));
+}
+
+:root[data-buzz-sidebar] .buzz-huddle-app-surface {
+ background-color: transparent;
+ background-image: none;
+ isolation: isolate;
+}
+
+/*
+ * Keep both Buzz gradients painted and switch only which layer is visible.
+ * WKWebView can retain the old raster when `background-image` is replaced on
+ * a translucent surface. The center content already follows `.dark`, so using
+ * that same state for layer visibility keeps both surfaces in lockstep.
+ */
+:root[data-buzz-sidebar] .buzz-theme-gradient-layer-light {
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-light-top),
+ var(--buzz-gradient-light-bottom)
+ );
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+:root[data-buzz-sidebar] .buzz-theme-gradient-layer-dark {
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-dark-top),
+ var(--buzz-gradient-dark-bottom)
+ );
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+:root[data-buzz-sidebar]:not(.dark) .buzz-theme-gradient-layer-light,
+:root[data-buzz-sidebar].dark .buzz-theme-gradient-layer-dark {
+ opacity: 1;
+}
+
+/* The mobile sidebar is portaled outside the app surface. */
+:root[data-buzz-sidebar]:not(.dark)
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-light-top),
+ var(--buzz-gradient-light-bottom)
+ );
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+:root[data-buzz-sidebar].dark
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-dark-top),
+ var(--buzz-gradient-dark-bottom)
+ );
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+/* All in-shell chrome reveals the single gradient painted above. */
:root[data-buzz-sidebar] .group\/sidebar-wrapper,
:root[data-buzz-sidebar] [data-testid="app-sidebar"],
:root[data-buzz-sidebar] [data-buzz-glass-inset],
@@ -237,42 +327,13 @@
:root[data-buzz-sidebar]
.group\/sidebar-wrapper
.bg-sidebar:not([data-buzz-flat]),
-:root[data-buzz-sidebar]
- [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar,
-:root[data-buzz-sidebar] [data-testid="community-rail"].bg-sidebar {
- background-color: transparent;
- background-image: linear-gradient(
- to bottom,
- var(--buzz-gradient-top),
- var(--buzz-gradient-bottom)
- );
- background-attachment: fixed;
- background-size: 100vw 100vh;
- background-repeat: no-repeat;
-}
-
-/*
- * The pinned header (search + nav) and the footer (profile card) are painted
- * with an opaque `--sidebar-background` fill plus a soft edge-fade pseudo-
- * element (see `components.css`). Under GitHub Light that fill is the flat
- * grey we are replacing, so it reads as an opaque panel floating over the
- * gradient. Repaint both regions with the SAME viewport-fixed gradient so
- * they line up seamlessly with the surrounding sidebar canvas, and drop the
- * edge-fade (there is no longer a distinct surface to fade into).
- */
+:root[data-buzz-sidebar] [data-testid="community-rail"].bg-sidebar,
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-testid="sidebar-pinned-header"],
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-sidebar="footer"] {
background-color: transparent;
- background-image: linear-gradient(
- to bottom,
- var(--buzz-gradient-top),
- var(--buzz-gradient-bottom)
- );
- background-attachment: fixed;
- background-size: 100vw 100vh;
- background-repeat: no-repeat;
+ background-image: none;
}
:root[data-buzz-sidebar]
@@ -288,12 +349,11 @@
* Active nav item. The accent system (`applyAccentColor` in ThemeProvider)
* sets `--sidebar-active` to the theme foreground as an INLINE style on
* :root, which renders the selected page as a solid black pill under GitHub
- * Light. For the Buzz theme we want a white pill instead. Override the two
- * active tokens on the sidebar element itself; element-local custom properties
- * win over the inherited inline values from :root. Text flips to the theme
- * foreground so it stays legible on the white pill; the derived
- * `bg-sidebar-active-foreground/20` badge on active items follows
- * automatically.
+ * Light. For the Buzz theme we want a subtle theme-specific tint instead.
+ * Override the two active tokens on the sidebar element itself; element-local
+ * custom properties win over the inherited inline values from :root. Text
+ * uses the theme foreground, and the derived
+ * `bg-sidebar-active-foreground/20` badge follows automatically.
*
* SCOPED to the app sidebar container, NOT :root - the `bg-sidebar-active` /
* `text-sidebar-active-foreground` tokens are also consumed by non-sidebar
@@ -319,36 +379,22 @@
}
/*
- * Active nav pill: drop the drop shadow so the white tab sits flat on the
- * gradient (Buzz-only; other themes keep `shadow-xs`). Higher specificity
- * than the `data-[active=true]:shadow-xs` utility and unlayered, so it wins.
+ * Active nav pill: use a subtle 7% black tint in light mode and drop the drop
+ * shadow so the selection sits flat on the gradient (Buzz-only; other themes
+ * keep `shadow-xs`). Higher specificity than the active utilities and
+ * unlayered, so it wins.
*/
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"],
:root[data-buzz-sidebar] [data-testid="settings-sidebar"] [data-active="true"] {
+ background-color: var(--buzz-active-surface);
box-shadow: none;
}
/*
- * On dark the active pill is a translucent white tint rather than a solid
- * fill, so it sits on the gradient without blowing out.
- */
-:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"],
-:root[data-buzz-sidebar].dark
- [data-testid="settings-sidebar"]
- [data-active="true"] {
- background-color: color-mix(
- in srgb,
- hsl(var(--buzz-active-fill)) 16%,
- transparent
- );
-}
-
-/*
- * Hover on non-active nav items uses a translucent white fill (see
- * `--buzz-hover-surface`) instead of the grey `--sidebar-accent`. Active
- * items are excluded - they stay solid on hover (their
- * `data-[active=true]:hover:bg-sidebar-active` rule). Covers both top-level
- * menu buttons and channel sub-buttons, in the app nav and the settings nav.
+ * Hover on non-active nav items uses the theme-specific translucent neutral
+ * fill instead of the grey `--sidebar-accent`. Active items are excluded so
+ * they retain the active surface. Covers both top-level menu buttons and
+ * channel sub-buttons, in the app nav and the settings nav.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
@@ -366,9 +412,22 @@
}
/*
- * Search box: match the same translucent white fill (see
- * `--buzz-hover-surface`) instead of the grey `bg-sidebar-border/35` fill, at
- * rest and on hover/focus.
+ * Row actions such as the DM close button are siblings of the menu button.
+ * Hovering them still activates the row's Tailwind `group-hover` treatment,
+ * so cover the whole menu item to keep that sibling-hover path on the same
+ * Buzz neutral surface instead of falling back to the legacy accent.
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ :where([data-sidebar="menu-item"]:hover)
+ > [data-sidebar="menu-button"]:not([data-active="true"]) {
+ background-color: var(--buzz-hover-surface);
+}
+
+/*
+ * Search box: use a neutral 7% tint instead of the grey
+ * `bg-sidebar-border/35` fill. Its label, shortcut, and magnifying glass share
+ * the 40% muted foreground at rest and on hover/focus.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
@@ -379,7 +438,45 @@
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-testid="open-search"]:focus-visible {
- background-color: var(--buzz-hover-surface);
+ background-color: var(--buzz-search-surface);
+ color: var(--buzz-muted-foreground);
+}
+
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="open-search"]
+ > * {
+ color: var(--buzz-muted-foreground);
+}
+
+/*
+ * App and settings sidebar section headings plus secondary footer text use the
+ * same tint. Navigation rows keep their normal foreground in both sidebars.
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-sidebar="group-label"],
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
+ [data-sidebar="group-label"],
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-buzz-sidebar-secondary],
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
+ [data-buzz-sidebar-secondary] {
+ color: var(--buzz-muted-foreground);
+}
+
+/* Sidebar toggle plus back/forward navigation controls in the top chrome. */
+:root[data-buzz-sidebar]
+ [data-testid="app-top-chrome"]
+ :is(
+ [data-sidebar="trigger"],
+ [data-testid="global-back"],
+ [data-testid="global-forward"]
+ ) {
+ color: var(--buzz-chrome-foreground);
}
/*
@@ -443,13 +540,13 @@
}
/*
- * Inbox + Agents nav rows: the pinned-header nav buttons (Inbox, Pulse,
+ * Inbox + Agents nav rows: the primary app nav buttons (Inbox, Pulse,
* Projects, Agents, Workflows). Excludes the active row so the active-pill
* foreground still wins.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
- [data-testid="sidebar-pinned-header"]
+ [data-testid="sidebar-primary-menu"]
[data-sidebar="menu-button"]:not([data-active="true"]) {
color: var(--buzz-nav-fg);
}
@@ -462,33 +559,25 @@
* surfaces pass through so the nav reads as one continuous pane.
*/
:root[data-buzz-translucent] {
- /* Neutral frosted wash; alpha applied via color-mix below. */
- --buzz-glass-tint: #ffffff;
- --buzz-glass-intensity: 1;
--buzz-translucency-gradient-alpha: 70%;
--buzz-translucency-wash-alpha: 2.4%;
}
-:root[data-buzz-translucent].dark {
- --buzz-glass-tint: #12161d;
-}
-:root[data-buzz-translucent] .group\/sidebar-wrapper,
-:root[data-buzz-translucent]
- [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar,
-:root[data-buzz-translucent] [data-testid="community-rail"].bg-sidebar {
- background-color: transparent;
+:root[data-buzz-translucent] .buzz-theme-gradient-layer-light {
/* Buzz color wash above a neutral frost wash. */
background-image:
linear-gradient(
to bottom,
color-mix(
in srgb,
- var(--buzz-gradient-top) var(--buzz-translucency-gradient-alpha, 70%),
+ var(--buzz-gradient-light-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
transparent
),
color-mix(
in srgb,
- var(--buzz-gradient-bottom) var(--buzz-translucency-gradient-alpha, 70%),
+ var(--buzz-gradient-light-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
transparent
)
),
@@ -496,16 +585,127 @@
to bottom,
color-mix(
in srgb,
- var(--buzz-glass-tint) var(--buzz-translucency-wash-alpha, 2.4%),
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
transparent
),
color-mix(
in srgb,
- var(--buzz-glass-tint) var(--buzz-translucency-wash-alpha, 2.4%),
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent] .buzz-theme-gradient-layer-dark {
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent]:not(.dark)
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-light-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-light-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent].dark
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
transparent
)
);
- background-attachment: fixed, fixed;
background-size:
100vw 100vh,
100vw 100vh;
@@ -559,9 +759,8 @@
background-image: none;
}
-/* Shell wrappers stay clear; the main content card remains opaque. */
-:root[data-buzz-translucent] .buzz-huddle-shell,
-:root[data-buzz-translucent] .buzz-huddle-app-surface {
+/* The outer shell stays clear; the app surface owns the gradient above. */
+:root[data-buzz-translucent] .buzz-huddle-shell {
background: transparent;
}
diff --git a/desktop/src/shared/theme/ThemePreviewFrame.tsx b/desktop/src/shared/theme/ThemePreviewFrame.tsx
index 8003b52fe7..5b4f8b1790 100644
--- a/desktop/src/shared/theme/ThemePreviewFrame.tsx
+++ b/desktop/src/shared/theme/ThemePreviewFrame.tsx
@@ -4,16 +4,22 @@ import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record;
/**
- * Buzz sidebar-gradient stop colors, keyed by theme name. Single source of
- * truth for the picker swatch — must stay in sync with the `--buzz-gradient-*`
- * values in `shared/styles/globals/theme.css`.
+ * Buzz sidebar-gradient stop tokens, keyed by theme name. The actual custom
+ * colors live once in `shared/styles/globals/theme.css`; the picker consumes
+ * those same semantic variables instead of duplicating raw values here.
*/
export const BUZZ_GRADIENT_STOPS: Record<
string,
{ top: string; bottom: string }
> = {
- buzz: { top: "#e6e6b6", bottom: "#c4d0da" },
- "buzz-dark": { top: "#2b2b18", bottom: "#1b2530" },
+ buzz: {
+ top: "var(--buzz-gradient-light-top)",
+ bottom: "var(--buzz-gradient-light-bottom)",
+ },
+ "buzz-dark": {
+ top: "var(--buzz-gradient-dark-top)",
+ bottom: "var(--buzz-gradient-dark-bottom)",
+ },
};
export const LIGHT_PREVIEW_VARS: ThemePreviewVars = {
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 6b41f4f057..d656815091 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -8,6 +8,7 @@ import {
useState,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
import { invokeTauri } from "@/shared/api/tauri";
import { isMacPlatform } from "@/shared/lib/platform";
import { createThemeVars, hexToHsl } from "./adaptive-theme";
@@ -244,8 +245,14 @@ function applyBuzzSidebar(themeName: string) {
const root = document.documentElement;
if (isBuzzTheme(themeName)) {
root.setAttribute("data-buzz-sidebar", "");
+ // Keep the concrete Buzz variant on the root as well as the generic
+ // marker. The gradient stylesheet matches this attribute directly, which
+ // makes WKWebView invalidate the painted background when light/dark mode
+ // changes instead of relying only on a custom-property dependency update.
+ root.setAttribute("data-buzz-theme", themeName);
} else {
root.removeAttribute("data-buzz-sidebar");
+ root.removeAttribute("data-buzz-theme");
// Leaving Buzz: drop translucency synchronously here too. Going *opaque*
// never shows desktop/prior content through, so there's no ordering risk
// on the way out — only on the way in.
@@ -293,6 +300,9 @@ let buzzVibrancyRequest = 0;
*/
let buzzVibrancyReady = false;
+/** The native layer does not need rebuilding when Buzz only changes mode. */
+let buzzVibrancyEnabled = false;
+
/**
* Enable the CSS translucency treatment, but only once BOTH prerequisites for
* the current request are in place:
@@ -338,6 +348,16 @@ function maybeEnableBuzzTranslucent(themeName: string, requestToken: number) {
async function applyBuzzVibrancy(themeName: string) {
const buzz = isBuzzTheme(themeName);
const requestToken = ++buzzVibrancyRequest;
+
+ // Buzz Light and Buzz Dark use the same native material. Rebuilding the
+ // NSVisualEffectView on every mode change briefly clears the layer behind
+ // the webview and makes the new CSS theme appear late. Keep the installed
+ // layer and let applyTheme swap only the color tokens.
+ if (buzz && buzzVibrancyEnabled && buzzVibrancyReady) {
+ maybeEnableBuzzTranslucent(themeName, requestToken);
+ return;
+ }
+
// A new request is in flight — the vibrancy layer's readiness for it is not
// yet known, so any stale "ready" from a prior request must not gate this one.
buzzVibrancyReady = false;
@@ -357,6 +377,7 @@ async function applyBuzzVibrancy(themeName: string) {
// A newer theme change superseded this request while the IPC was in flight;
// that later call owns the current translucency state, so don't clobber it.
if (requestToken !== buzzVibrancyRequest) return;
+ buzzVibrancyEnabled = buzz;
// Native layer is installed. Record readiness and try to enable translucency
// — but only if `applyBuzzSidebar` has already installed the Buzz gradient
// vars. If that effect hasn't landed yet (the IPC won the race), it will
@@ -369,6 +390,7 @@ async function applyBuzzVibrancy(themeName: string) {
console.warn("set_window_vibrancy failed", error);
if (requestToken !== buzzVibrancyRequest) return;
// Vibrancy failed — don't go transparent or we'd show through to nothing.
+ buzzVibrancyEnabled = false;
setBuzzTranslucent(false);
}
}
@@ -400,9 +422,17 @@ function applyCachedVars(): string | null {
}
}
+/** The latest theme load is the only one allowed to write document styles. */
+let themeApplyRequest = 0;
+
/** Apply a theme: load data, derive CSS vars, set them on :root. */
-async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
+async function applyTheme(
+ name: SyntaxThemeName,
+): Promise<{ isDark: boolean } | null> {
+ const requestToken = ++themeApplyRequest;
const themeData = await loadThemeData(name);
+ if (requestToken !== themeApplyRequest) return null;
+
const info = extractThemeInfo(name, themeData);
const { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
added: info.added,
@@ -498,12 +528,13 @@ export function ThemeProvider({
loadingRef.current = thisTheme;
setIsLoading(true);
- applyTheme(effectiveTheme as SyntaxThemeName).then(({ isDark: dark }) => {
+ applyTheme(effectiveTheme as SyntaxThemeName).then((result) => {
+ if (!result) return;
// Only update if this is still the theme we want. The accent is applied
// inside applyTheme (synchronously with the theme vars), so there's no
// separate re-application here — that avoided the switch-time flicker.
if (loadingRef.current === thisTheme) {
- setIsDark(dark);
+ setIsDark(result.isDark);
setIsLoading(false);
}
});
@@ -519,13 +550,41 @@ export function ThemeProvider({
if (!followSystem) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
- const handler = (event: MediaQueryListEvent) => {
+ const handleMediaChange = (event: MediaQueryListEvent) => {
setSystemIsDark(event.matches);
};
+ let disposed = false;
+ let unlistenNativeTheme: (() => void) | undefined;
setSystemIsDark(mq.matches);
- mq.addEventListener("change", handler);
- return () => mq.removeEventListener("change", handler);
+ mq.addEventListener("change", handleMediaChange);
+
+ // WKWebView can update the media query value without dispatching its
+ // change event until the page reloads. Tauri's native window event arrives
+ // immediately when macOS appearance changes, so use it as the reliable app
+ // signal while retaining matchMedia for the browser build.
+ if (isTauri()) {
+ void getCurrentWindow()
+ .onThemeChanged(({ payload }) => {
+ if (!disposed) setSystemIsDark(payload === "dark");
+ })
+ .then((unlisten) => {
+ if (disposed) {
+ unlisten();
+ } else {
+ unlistenNativeTheme = unlisten;
+ }
+ })
+ .catch((error) => {
+ console.warn("system theme listener unavailable", error);
+ });
+ }
+
+ return () => {
+ disposed = true;
+ mq.removeEventListener("change", handleMediaChange);
+ unlistenNativeTheme?.();
+ };
}, [followSystem]);
// Re-apply the accent when the user picks a new swatch or the effective theme
diff --git a/desktop/src/shared/ui/sidebar-menu-label.tsx b/desktop/src/shared/ui/sidebar-menu-label.tsx
new file mode 100644
index 0000000000..5b6e4b5128
--- /dev/null
+++ b/desktop/src/shared/ui/sidebar-menu-label.tsx
@@ -0,0 +1,31 @@
+import * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+
+/**
+ * Keeps a menu label's inline footprint stable when its parent changes font
+ * weight for the active state. The invisible semibold copy reserves the
+ * widest state while the visible copy remains the accessible label.
+ */
+const SidebarMenuLabel = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentProps<"span">
+>(({ children, className, ...props }, ref) => (
+
+
+ {children}
+
+ {children}
+
+));
+SidebarMenuLabel.displayName = "SidebarMenuLabel";
+
+export { SidebarMenuLabel };
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index a2f9263fbb..f9ae719999 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -7272,6 +7272,7 @@ async function handleStartManagedAgent(
agent.updated_at = now;
agent.last_started_at = now;
agent.last_error = null;
+ setMockPresenceStatus(agent.pubkey, "online");
agent.log_lines.push(
agent.backend.type === "provider"
? `deployed mock provider harness at ${now}`
@@ -7290,6 +7291,7 @@ async function handleStopManagedAgent(args: {
agent.pid = null;
agent.updated_at = now;
agent.last_stopped_at = now;
+ setMockPresenceStatus(agent.pubkey, "offline");
agent.log_lines.push(`stopped mock harness at ${now}`);
syncMockRelayAgentsFromManagedAgents();
return cloneManagedAgent(agent);
@@ -7696,6 +7698,10 @@ async function handleSendManagedAgentChannelMessage(
channelId: string;
content: string;
marker?: string | null;
+ markerScope?: "agent" | "channel" | null;
+ mentionPubkeys?: string[] | null;
+ parentEventId?: string | null;
+ additionalMarkers?: string[] | null;
},
_config: E2eConfig | undefined,
): Promise {
@@ -7704,25 +7710,41 @@ async function handleSendManagedAgentChannelMessage(
if (marker) {
const existing = getMockMessageStore(args.channelId).find(
(event) =>
- event.pubkey === agent.pubkey &&
+ (args.markerScope === "channel" || event.pubkey === agent.pubkey) &&
event.tags.some((tag) => tag[0] === "client" && tag[1] === marker),
);
if (existing) {
return {
event_id: existing.id,
- parent_event_id: null,
- root_event_id: null,
- depth: 0,
+ parent_event_id: args.parentEventId ?? null,
+ root_event_id: args.parentEventId ?? null,
+ depth: args.parentEventId ? 1 : 0,
created_at: existing.created_at,
};
}
}
const createdAt = Math.floor(Date.now() / 1000);
+ const tags = args.parentEventId
+ ? buildReplyMessageTags(
+ args.channelId,
+ agent.pubkey,
+ args.parentEventId,
+ args.parentEventId,
+ args.mentionPubkeys ?? undefined,
+ )
+ : buildTopLevelMessageTags(
+ args.channelId,
+ args.mentionPubkeys ?? undefined,
+ agent.pubkey,
+ );
+ for (const clientMarker of [marker, ...(args.additionalMarkers ?? [])]) {
+ if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]);
+ }
const event = createMockEvent(
9,
args.content.trim(),
- [["h", args.channelId], ...(marker ? [["client", marker]] : [])],
+ tags,
agent.pubkey,
createdAt,
);
@@ -7731,9 +7753,9 @@ async function handleSendManagedAgentChannelMessage(
return {
event_id: event.id,
- parent_event_id: null,
- root_event_id: null,
- depth: 0,
+ parent_event_id: args.parentEventId ?? null,
+ root_event_id: args.parentEventId ?? null,
+ depth: args.parentEventId ? 1 : 0,
created_at: createdAt,
};
}
@@ -9267,6 +9289,8 @@ export function maybeInstallE2eTauriMocks() {
return handleStopManagedAgent(
payload as Parameters[0],
);
+ case "set_agent_managed_profiles":
+ return undefined;
case "set_managed_agent_auto_restart":
return handleSetManagedAgentAutoRestart(
payload as Parameters[0],
@@ -9519,6 +9543,22 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
activeConfig,
);
+ case "has_managed_agent_channel_message_marker": {
+ const args = payload as {
+ channelId: string;
+ marker: string;
+ agentPubkey?: string | null;
+ markerScope?: "agent" | "channel" | null;
+ };
+ return getMockMessageStore(args.channelId).some(
+ (event) =>
+ (args.markerScope === "channel" ||
+ event.pubkey === args.agentPubkey) &&
+ event.tags.some(
+ (tag) => tag[0] === "client" && tag[1] === args.marker,
+ ),
+ );
+ }
case "send_managed_agent_channel_message":
return handleSendManagedAgentChannelMessage(
payload as Parameters[0],
@@ -9776,6 +9816,8 @@ export function maybeInstallE2eTauriMocks() {
// The spec only verifies UI state, not the submitted request shape;
// returning null mirrors the Rust submit_event success path.
return null;
+ case "set_canvas":
+ return { ok: true, event_id: mockEventId() };
case "get_canvas": {
const canvasReadError = activeConfig?.mock?.canvasReadError;
if (canvasReadError) {
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index a1e17bd2b5..fc42aed160 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -10,6 +10,13 @@ export default {
fontSize: {
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
+ // 40px — onboarding page titles (tightened tracking for large display type)
+ title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }],
+ // 36px — the backup-step private key, shown large in monospace
+ "nsec-key": ["2.25rem", { lineHeight: "1.3" }],
+ },
+ boxShadow: {
+ "content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
},
borderRadius: {
lg: "var(--radius)",
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index 21ca0bd349..c37d4eb7d8 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -1121,25 +1121,9 @@ test("share access controls include the selected memories", async ({
.getByRole("menuitemradio", { name: "Agent + core memory" })
.click();
await expect(linkAccess).toHaveText("Agent + core memory");
- const shareCardHeightSamples = await shareMainCard.evaluate(
- async (element) => {
- const samples: number[] = [];
- const start = performance.now();
-
- await new Promise((resolve) => {
- const sample = (now: number) => {
- samples.push(element.getBoundingClientRect().height);
- if (now - start >= 280) {
- resolve();
- return;
- }
- requestAnimationFrame(sample);
- };
- requestAnimationFrame(sample);
- });
-
- return samples;
- },
+ await waitForAnimations(page);
+ const expandedShareCardHeight = await shareMainCard.evaluate(
+ (element) => element.getBoundingClientRect().height,
);
const inlineMemoryWarning = shareDialog.getByTestId(
"persona-share-memory-warning",
@@ -1151,10 +1135,7 @@ test("share access controls include the selected memories", async ({
await expect(inlineMemoryWarning).toContainText(
"Only share it with people you trust.",
);
- expect(shareCardHeightSamples.at(-1)).toBeGreaterThan(initialShareCardHeight);
- expect(
- new Set(shareCardHeightSamples.map((height) => Math.round(height))).size,
- ).toBeGreaterThan(2);
+ expect(expandedShareCardHeight).toBeGreaterThan(initialShareCardHeight);
await page.getByTestId("persona-share-copy-link").click();
const memoryConfirmation = page.getByTestId(
"persona-share-memory-confirmation",
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index cb55c64f76..991479e06e 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -5,6 +5,8 @@ import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/buzz-theme";
const THEME_STORAGE_KEY = "buzz-theme";
+const MOCK_PUBKEY = "deadbeef".repeat(8);
+const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
/**
* Seed the active theme into localStorage BEFORE the mock bridge installs so
@@ -20,6 +22,29 @@ async function seedTheme(page: Page, theme: string) {
);
}
+async function seedIconChannelSection(page: Page) {
+ await page.addInitScript(
+ ({ channelId, pubkey }) => {
+ window.localStorage.setItem(
+ `buzz-channel-sections.v1:${pubkey}`,
+ JSON.stringify({
+ version: 1,
+ sections: [
+ {
+ id: "alignment-section",
+ name: "Team channels",
+ icon: "📌",
+ order: 0,
+ },
+ ],
+ assignments: { [channelId]: "alignment-section" },
+ }),
+ );
+ },
+ { channelId: GENERAL_CHANNEL_ID, pubkey: MOCK_PUBKEY },
+ );
+}
+
async function openChannel(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
@@ -27,10 +52,317 @@ async function openChannel(page: Page) {
await expect(page.getByTestId("app-sidebar")).toBeVisible();
}
+async function expectBuzzSidebarPalette(page: Page, mode: "light" | "dark") {
+ const mutedColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
+ const searchSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
+ const hoverSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
+ const activeSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.07)" : "color(srgb 1 1 1 / 0.16)";
+ const chromeColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.5)";
+ const search = page.getByTestId("open-search");
+ const pinnedHeader = page.getByTestId("sidebar-pinned-header");
+ const sidebarScroller = page.locator(".buzz-sidebar-scrollbar");
+ const scrollContent = page.getByTestId("sidebar-scroll-content");
+ const primaryMenu = page.getByTestId("sidebar-primary-menu");
+ const sectionLabel = page
+ .locator('[data-sidebar="group-label"]')
+ .filter({ hasText: "Channels" })
+ .first();
+
+ await expect(sectionLabel).toHaveCSS("color", mutedColor);
+ await expect(search).toHaveCSS("background-color", searchSurface);
+ await expect(search.locator("svg").first()).toHaveCSS("color", mutedColor);
+ await expect(search.locator("span").first()).toHaveCSS("color", mutedColor);
+ await expect(pinnedHeader).toHaveCSS("padding-bottom", "8px");
+ await expect(pinnedHeader).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await expect(pinnedHeader).toHaveCSS("margin-left", "3px");
+ await expect(pinnedHeader).toHaveCSS("margin-right", "3px");
+ await expect(pinnedHeader).toHaveCSS("padding-right", "8px");
+ await expect(sidebarScroller).toHaveCSS("padding-left", "0px");
+ await expect(sidebarScroller).toHaveCSS("padding-right", "0px");
+ await expect(scrollContent).toHaveCSS("padding-left", "3px");
+ await expect(scrollContent).toHaveCSS("padding-right", "3px");
+ const pinnedSpacerColor = await pinnedHeader.evaluate(
+ (element) => getComputedStyle(element, "::before").backgroundColor,
+ );
+ expect(pinnedSpacerColor).toBe("rgba(0, 0, 0, 0)");
+ await expect(sidebarScroller.getByTestId("open-agents-view")).toBeVisible();
+ const searchBox = await search.boundingBox();
+ const pinnedHeaderBox = await pinnedHeader.boundingBox();
+ const primaryMenuBox = await primaryMenu.boundingBox();
+ const primaryRowBox = await page
+ .getByTestId("open-agents-view")
+ .boundingBox();
+ const activeRowBox = await page.getByTestId("channel-general").boundingBox();
+ const hoverRowBox = await page.getByTestId("channel-random").boundingBox();
+ const scrollContentBox = await scrollContent.evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ return { left: box.left, right: box.right };
+ });
+ expect(searchBox).not.toBeNull();
+ expect(pinnedHeaderBox).not.toBeNull();
+ expect(primaryMenuBox).not.toBeNull();
+ expect(primaryRowBox).not.toBeNull();
+ expect(activeRowBox).not.toBeNull();
+ expect(hoverRowBox).not.toBeNull();
+ if (
+ !searchBox ||
+ !pinnedHeaderBox ||
+ !primaryMenuBox ||
+ !primaryRowBox ||
+ !activeRowBox ||
+ !hoverRowBox
+ ) {
+ throw new Error("Sidebar search or primary navigation geometry is missing");
+ }
+ expect(primaryMenuBox.y - (searchBox.y + searchBox.height)).toBe(8);
+ expect(
+ pinnedHeaderBox.y +
+ pinnedHeaderBox.height -
+ (searchBox.y + searchBox.height),
+ ).toBe(8);
+ expect(primaryMenuBox.y - (pinnedHeaderBox.y + pinnedHeaderBox.height)).toBe(
+ 0,
+ );
+ for (const rowBox of [primaryRowBox, activeRowBox, hoverRowBox]) {
+ expect(Math.abs(rowBox.x - searchBox.x)).toBeLessThanOrEqual(0.5);
+ // Linux CI reserves a classic scrollbar gutter while macOS uses an
+ // overlay scrollbar. Compare each row to its usable scroll area so the
+ // alignment check remains platform-independent.
+ const rowLeftSpacing = rowBox.x - scrollContentBox.left;
+ const rowRightSpacing = scrollContentBox.right - (rowBox.x + rowBox.width);
+ expect(Math.abs(rowLeftSpacing - rowRightSpacing)).toBeLessThanOrEqual(0.5);
+ }
+ await expect(page.locator("[data-buzz-sidebar-secondary]").first()).toHaveCSS(
+ "color",
+ mutedColor,
+ );
+ await expect(page.locator('[data-sidebar="trigger"]')).toHaveCSS(
+ "color",
+ chromeColor,
+ );
+ await expect(page.getByTestId("global-back")).toHaveCSS("color", chromeColor);
+ await expect(page.getByTestId("global-forward")).toHaveCSS(
+ "color",
+ chromeColor,
+ );
+ await expect(page.getByTestId("channel-general")).not.toHaveCSS(
+ "color",
+ mutedColor,
+ );
+ await expect(page.getByTestId("channel-general")).toHaveCSS(
+ "background-color",
+ activeSurface,
+ );
+ const hoverChannel = page.getByTestId("channel-random");
+ await hoverChannel.hover();
+ await expect(hoverChannel).toHaveCSS("background-color", hoverSurface);
+
+ const firstDmItem = page
+ .getByTestId("dm-list")
+ .locator('[data-sidebar="menu-item"]')
+ .first();
+ const firstDmButton = firstDmItem.locator('[data-sidebar="menu-button"]');
+ const closeDmButton = firstDmItem.getByRole("button", {
+ name: "Close direct message",
+ });
+ await firstDmItem.hover();
+ await expect(closeDmButton).toBeVisible();
+ await closeDmButton.hover();
+ await expect(firstDmButton).toHaveCSS("background-color", hoverSurface);
+
+ const scrollbarThumbColor = await sidebarScroller.evaluate(
+ (element) =>
+ getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor,
+ );
+ expect(scrollbarThumbColor).toBe(hoverSurface);
+}
+
+async function expectIconlessSectionTitleAligned(
+ page: Page,
+ listTestId: "stream-list" | "dm-list",
+) {
+ const titleBox = await page
+ .getByTestId(`${listTestId}-section-label`)
+ .locator("[data-sidebar-section-title]")
+ .boundingBox();
+ const firstRowIconX = await page
+ .getByTestId(listTestId)
+ .locator('[data-sidebar="menu-button"]')
+ .first()
+ .evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ const paddingLeft = Number.parseFloat(
+ getComputedStyle(element).paddingLeft,
+ );
+ return box.x + paddingLeft;
+ });
+
+ expect(titleBox).not.toBeNull();
+ if (!titleBox) {
+ throw new Error(`Sidebar section ${listTestId} is missing label geometry`);
+ }
+ expect(Math.abs(titleBox.x - firstRowIconX)).toBeLessThanOrEqual(0.5);
+}
+
+async function expectBuzzContentShadow(page: Page, mode: "light" | "dark") {
+ const effects = await page.evaluate(() => {
+ const shell = document.querySelector(".buzz-huddle-shell");
+ const content = document.querySelector("[data-buzz-content-surface]");
+ const shadowViewport = document.querySelector(
+ "[data-buzz-shadow-viewport]",
+ );
+ return {
+ appStroke: shell ? getComputedStyle(shell, "::before").boxShadow : "",
+ contentShadow: content ? getComputedStyle(content).boxShadow : "",
+ shadowViewportOverflow: shadowViewport
+ ? getComputedStyle(shadowViewport).overflow
+ : "",
+ };
+ });
+
+ expect(effects.appStroke).toBe("none");
+ if (mode === "light") {
+ expect(effects.contentShadow).toContain("4px");
+ expect(effects.contentShadow).toContain("rgba(0, 0, 0, 0.07)");
+ expect(effects.shadowViewportOverflow).toBe("visible");
+ } else {
+ expect(effects.contentShadow).not.toContain("4px");
+ expect(effects.contentShadow).not.toContain("rgba(255, 255, 255, 0.07)");
+ expect(effects.shadowViewportOverflow).toBe("hidden");
+ }
+}
+
+async function expectBuzzGradientPaint(
+ page: Page,
+ mode: "light" | "dark",
+): Promise {
+ const paint = await page.evaluate(() => {
+ const root = document.documentElement;
+ const appSurface = document.querySelector(".buzz-huddle-app-surface");
+ const lightLayer = document.querySelector('[data-buzz-gradient="light"]');
+ const darkLayer = document.querySelector('[data-buzz-gradient="dark"]');
+ const sidebarRoot = document.querySelector(
+ '[data-testid="app-sidebar"], [data-testid="settings-sidebar"]',
+ );
+ const sidebarSurface =
+ sidebarRoot?.querySelector('[data-sidebar="sidebar"]') ?? sidebarRoot;
+ const appStyles = appSurface ? getComputedStyle(appSurface) : null;
+ const lightStyles = lightLayer ? getComputedStyle(lightLayer) : null;
+ const darkStyles = darkLayer ? getComputedStyle(darkLayer) : null;
+ return {
+ isDark: root.classList.contains("dark"),
+ theme: root.getAttribute("data-buzz-theme"),
+ surfaceImage: appStyles?.backgroundImage ?? "",
+ lightImage: lightStyles?.backgroundImage ?? "",
+ lightOpacity: lightStyles?.opacity ?? "",
+ darkImage: darkStyles?.backgroundImage ?? "",
+ darkOpacity: darkStyles?.opacity ?? "",
+ sidebarImage: sidebarSurface
+ ? getComputedStyle(sidebarSurface).backgroundImage
+ : "",
+ };
+ });
+
+ expect(paint.theme).toBe(mode === "light" ? "buzz" : "buzz-dark");
+ expect(paint.isDark).toBe(mode === "dark");
+ expect(paint.surfaceImage).toBe("none");
+ expect(paint.lightImage).not.toBe("");
+ expect(paint.lightImage).not.toBe("none");
+ expect(paint.darkImage).not.toBe("");
+ expect(paint.darkImage).not.toBe("none");
+ expect(paint.lightImage).not.toBe(paint.darkImage);
+ expect(paint.lightOpacity).toBe(mode === "light" ? "1" : "0");
+ expect(paint.darkOpacity).toBe(mode === "dark" ? "1" : "0");
+ expect(paint.sidebarImage).toBe("none");
+ return mode === "light" ? paint.lightImage : paint.darkImage;
+}
+
+async function expectBuzzSettingsPalette(page: Page, mode: "light" | "dark") {
+ const mutedColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
+ const sidebar = page.getByTestId("settings-sidebar");
+ const sectionLabel = sidebar
+ .locator('[data-sidebar="group-label"]')
+ .filter({ hasText: "Personal" });
+
+ await expect(sectionLabel).toHaveCSS("color", mutedColor);
+ await expect(page.getByTestId("settings-nav-profile")).not.toHaveCSS(
+ "color",
+ mutedColor,
+ );
+
+ await expectBuzzGradientPaint(page, mode);
+
+ const version = page.getByTestId("settings-version");
+ if ((await version.count()) > 0) {
+ await expect(version).toHaveCSS("color", mutedColor);
+ }
+}
+
+async function expectAppliedBuzzTheme(
+ page: Page,
+ themeName: "buzz" | "buzz-dark",
+ storedTheme: "buzz" | "buzz-dark" = themeName,
+) {
+ const isDark = themeName === "buzz-dark";
+ await expect
+ .poll(() =>
+ page.evaluate((storageKey) => {
+ const root = document.documentElement;
+ const styles = getComputedStyle(root);
+ return {
+ storedTheme: window.localStorage.getItem(storageKey),
+ isDark: root.classList.contains("dark"),
+ buzzTheme: root.getAttribute("data-buzz-theme"),
+ gradientTop: styles.getPropertyValue("--buzz-gradient-top").trim(),
+ gradientBottom: styles
+ .getPropertyValue("--buzz-gradient-bottom")
+ .trim(),
+ };
+ }, THEME_STORAGE_KEY),
+ )
+ .toEqual({
+ storedTheme,
+ isDark,
+ buzzTheme: themeName,
+ gradientTop: isDark ? "#4a4616" : "#e6e6b6",
+ gradientBottom: isDark ? "#0a1423" : "#c4d0da",
+ });
+}
+
+async function emitNativeThemeChange(page: Page, theme: "light" | "dark") {
+ await page.evaluate(async (nextTheme) => {
+ const tauriWindow = window as typeof window & {
+ __TAURI_INTERNALS__?: {
+ invoke?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ };
+ };
+ const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
+ if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
+ await invoke("plugin:event|emit", {
+ event: "tauri://theme-changed",
+ payload: nextTheme,
+ });
+ }, theme);
+}
+
test("buzz light sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openChannel(page);
+ await expectBuzzGradientPaint(page, "light");
+ await expectBuzzSidebarPalette(page, "light");
+ await expectBuzzContentShadow(page, "light");
+ await expectIconlessSectionTitleAligned(page, "stream-list");
+ await expectIconlessSectionTitleAligned(page, "dm-list");
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
@@ -41,12 +373,64 @@ test("buzz dark sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installMockBridge(page);
await openChannel(page);
+ await expectBuzzGradientPaint(page, "dark");
+ await expectBuzzSidebarPalette(page, "dark");
+ await expectBuzzContentShadow(page, "dark");
+ await expectIconlessSectionTitleAligned(page, "stream-list");
+ await expectIconlessSectionTitleAligned(page, "dm-list");
+ await expect(page.locator("[data-buzz-content-surface]")).toHaveCSS(
+ "background-color",
+ "rgb(26, 26, 26)",
+ );
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
.screenshot({ path: `${SHOTS}/02-buzz-dark-sidebar.png` });
});
+test("custom section icon and name align with channel columns", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await seedIconChannelSection(page);
+ await installMockBridge(page);
+ await openChannel(page);
+
+ const sectionIconBox = await page
+ .getByTestId("section-icon-alignment-section")
+ .boundingBox();
+ const sectionTitleBox = await page
+ .getByTestId("section-title-alignment-section")
+ .boundingBox();
+ const channelButton = page.getByTestId("channel-general");
+ const channelIconBox = await channelButton
+ .locator("svg")
+ .first()
+ .boundingBox();
+ const channelTitleBox = await channelButton
+ .locator("[data-sidebar-row-label]")
+ .boundingBox();
+
+ expect(sectionIconBox).not.toBeNull();
+ expect(sectionTitleBox).not.toBeNull();
+ expect(channelIconBox).not.toBeNull();
+ expect(channelTitleBox).not.toBeNull();
+ if (
+ !sectionIconBox ||
+ !sectionTitleBox ||
+ !channelIconBox ||
+ !channelTitleBox
+ ) {
+ throw new Error("Custom section alignment geometry is missing");
+ }
+ expect(Math.abs(sectionIconBox.x - channelIconBox.x)).toBeLessThanOrEqual(
+ 0.5,
+ );
+ expect(Math.abs(sectionTitleBox.x - channelTitleBox.x)).toBeLessThanOrEqual(
+ 0.5,
+ );
+});
+
async function openAppearance(page: Page, mode: "system" | "light" | "dark") {
// Settings renders at the AppShell level; open it via the profile card
// button, then select the Appearance section.
@@ -90,9 +474,23 @@ test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => {
await page.getByTestId("profile-popover-settings").click();
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
+ const profileRow = page.getByTestId("settings-nav-profile");
+ const profileLabel = profileRow.locator('[data-sidebar="menu-label"]');
+ await expect(profileRow).toHaveAttribute("data-active", "true");
+ await expect(profileRow).toHaveCSS("font-weight", "600");
+ const selectedLabelBox = await profileLabel.boundingBox();
// Appearance is the active section here; its nav row should carry the Buzz
// white active pill (data-active=true), matching the Left Nav treatment.
await page.getByTestId("settings-nav-appearance").click();
+ await expect(profileRow).toHaveCSS("font-weight", "400");
+ const unselectedLabelBox = await profileLabel.boundingBox();
+ expect(selectedLabelBox).not.toBeNull();
+ expect(unselectedLabelBox).not.toBeNull();
+ if (!selectedLabelBox || !unselectedLabelBox) {
+ throw new Error("Settings nav label geometry is missing");
+ }
+ expect(Math.abs(selectedLabelBox.width - unselectedLabelBox.width)).toBe(0);
+ await expectBuzzSettingsPalette(page, "light");
const activeRow = page.getByTestId("settings-nav-appearance");
await expect(activeRow).toHaveAttribute("data-active", "true");
await waitForAnimations(page);
@@ -108,8 +506,64 @@ test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
await page.getByTestId("settings-nav-appearance").click();
+ await expectBuzzSettingsPalette(page, "dark");
+ await expect(page.getByTestId("settings-content-surface")).toHaveCSS(
+ "background-color",
+ "rgb(26, 26, 26)",
+ );
await waitForAnimations(page);
await sidebar.screenshot({ path: `${SHOTS}/07-settings-nav-dark.png` });
+ await page.getByTestId("settings-view").screenshot({
+ path: `${SHOTS}/09-settings-content-dark.png`,
+ });
+});
+
+test("settings content uses the same inset surface as the main app", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ const searchBox = await page.getByTestId("open-search").boundingBox();
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+
+ const settingsView = page.getByTestId("settings-view");
+ const contentSurface = page.getByTestId("settings-content-surface");
+ const backToAppBox = await page
+ .getByTestId("settings-back-to-app")
+ .boundingBox();
+ await expect(contentSurface).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId("settings-content-scroll")).toHaveCSS(
+ "padding-top",
+ "24px",
+ );
+
+ const viewBox = await settingsView.boundingBox();
+ const surfaceBox = await contentSurface.boundingBox();
+ expect(searchBox).not.toBeNull();
+ expect(backToAppBox).not.toBeNull();
+ expect(viewBox).not.toBeNull();
+ expect(surfaceBox).not.toBeNull();
+ if (!searchBox || !backToAppBox || !viewBox || !surfaceBox) {
+ throw new Error("Settings layout is missing");
+ }
+
+ expect(Math.abs(backToAppBox.y - searchBox.y)).toBeLessThanOrEqual(0.5);
+
+ // Match the normal app shell: a fixed 40px top chrome strip, then a 1px
+ // top/left inset and 8px right/bottom inset around the rounded content card.
+ expect(surfaceBox.y - viewBox.y).toBe(41);
+ expect(surfaceBox.x - viewBox.x).toBe(1);
+ expect(viewBox.x + viewBox.width - (surfaceBox.x + surfaceBox.width)).toBe(8);
+ expect(viewBox.y + viewBox.height - (surfaceBox.y + surfaceBox.height)).toBe(
+ 8,
+ );
+
+ await waitForAnimations(page);
+ await settingsView.screenshot({
+ path: `${SHOTS}/08-settings-content-inset.png`,
+ });
});
test("appearance hides accent picker under Buzz", async ({ page }) => {
@@ -119,7 +573,7 @@ test("appearance hides accent picker under Buzz", async ({ page }) => {
// The accent picker is hidden while a Buzz theme is active. Its neutral
// swatch testid must not be present.
await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
- await panel.screenshot({ path: `${SHOTS}/08-appearance-no-accent.png` });
+ await panel.screenshot({ path: `${SHOTS}/10-appearance-no-accent.png` });
});
test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
@@ -140,3 +594,46 @@ test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
await page.getByTestId("theme-option-github-light").click();
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
});
+
+test("Buzz light and dark modes apply live without a reload", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await openAppearance(page, "light");
+ await expectAppliedBuzzTheme(page, "buzz");
+ const lightGradient = await expectBuzzGradientPaint(page, "light");
+
+ await page.getByTestId("appearance-mode-dark").click();
+ await expectAppliedBuzzTheme(page, "buzz-dark");
+ const darkGradient = await expectBuzzGradientPaint(page, "dark");
+ expect(darkGradient).not.toBe(lightGradient);
+
+ await page.getByTestId("appearance-mode-light").click();
+ await expectAppliedBuzzTheme(page, "buzz");
+ await expectBuzzGradientPaint(page, "light");
+
+ // Exercise the overlap that previously let a slower, stale theme load win.
+ await page.getByTestId("appearance-mode-dark").click();
+ await page.getByTestId("appearance-mode-light").click();
+ await expectAppliedBuzzTheme(page, "buzz");
+});
+
+test("Buzz follows native system theme changes without a reload", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await page.addInitScript(() => {
+ (window as typeof window & { isTauri?: boolean }).isTauri = true;
+ });
+ await installMockBridge(page);
+ await openAppearance(page, "system");
+
+ await emitNativeThemeChange(page, "dark");
+ await expectAppliedBuzzTheme(page, "buzz-dark", "buzz");
+ await expectBuzzGradientPaint(page, "dark");
+
+ await emitNativeThemeChange(page, "light");
+ await expectAppliedBuzzTheme(page, "buzz", "buzz");
+ await expectBuzzGradientPaint(page, "light");
+});
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index dbeb84143f..2ba35c1cdf 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -17,7 +17,8 @@ test("normal first launch uses the already-persisted identity", async ({
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await expect(gate).toHaveCSS("background-color", "rgb(215, 215, 46)");
- await expect(gate).toHaveCSS("background-image", "none");
+ // Landing carries a subtle dot-grid pattern over the chartreuse fill.
+ await expect(gate).toHaveCSS("background-image", /radial-gradient/);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(page.getByRole("button", { name: "Get started" })).toHaveCSS(
"background-color",
@@ -30,9 +31,10 @@ test("normal first launch uses the already-persisted identity", async ({
name: "Your unique identity has been created",
}),
).toBeVisible();
+ // Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
await expect(gate).toHaveCSS(
"background-image",
- "linear-gradient(rgb(215, 215, 46), rgb(215, 231, 246))",
+ /radial-gradient\(.*\), linear-gradient\(.*rgb\(215, 215, 46\).*rgb\(215, 231, 246\)\)/s,
);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
const commands = await page.evaluate(
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 4355709f7b..0cd82483e8 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -6,6 +6,8 @@ import {
TEST_IDENTITIES,
} from "../helpers/bridge";
+const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8);
+
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -26,6 +28,8 @@ const CASEY_PROFILE_PUBKEY =
"1111111111111111111111111111111111111111111111111111111111111111";
const PROFILE_ONLY_AGENT_PUBKEY =
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
+const OWNED_AGENT_PROFILE_PUBKEY =
+ "1212121212121212121212121212121212121212121212121212121212121212";
const SYSTEM_MESSAGE_KIND = 40099;
const DM_THREAD_AGENT_MENTION_ERROR_TEXT =
"Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
@@ -1699,6 +1703,150 @@ test("clicking a mention chip in a forum post opens the profile panel", async ({
);
});
+test("agent profile popover shows its owner", async ({ page }) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: TEST_IDENTITIES.bob.pubkey,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by bob");
+});
+
+test("agent profile popover labels an agent owned by the viewer as you", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: MOCK_VIEWER_PUBKEY,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by you");
+});
+
+test("agent profile popover falls back to the owner's pubkey", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: CASEY_PROFILE_PUBKEY,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by 11111111…1111");
+});
+
+test("human profile popover does not show an owner", async ({ page }) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bob checking in.", {
+ pubkey: TEST_IDENTITIES.bob.pubkey,
+ });
+ await waitForTimelineSettled(page);
+
+ const bobMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bob checking in." })
+ .first();
+ await bobMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.locator('[data-testid^="user-profile-popover-owner-"]'),
+ ).toHaveCount(0);
+});
+
test("owned bot profile only exposes message action", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
index 21dfe1a270..93e8d473e3 100644
--- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
+++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
@@ -5,7 +5,7 @@ import { passThroughBackupStep } from "../helpers/onboarding";
const SHOTS = "test-results/screenshots-onboarding";
-/** Drive to the setup page (page 2) via the full onboarding flow. */
+/** Drive to the harness setup page (page 3) via the full onboarding flow. */
async function navigateToSetupPage(
page: Parameters[0],
) {
@@ -14,29 +14,36 @@ async function navigateToSetupPage(
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
}
-test("setup page shows Agent defaults section with readiness badge", async ({
- page,
-}) => {
+/** Drive to the default config page (page 4), past the harness page. */
+async function navigateToConfigPage(
+ page: Parameters[0],
+) {
+ await navigateToSetupPage(page);
+ await page.getByTestId("onboarding-setup-next").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
+}
+
+test("config page shows Agent defaults form", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
- await navigateToSetupPage(page);
+ await navigateToConfigPage(page);
- const badge = page.getByTestId("agent-readiness-badge");
- await expect(badge).toBeVisible();
+ // The defaults form is the page's content; no readiness badge is shown.
+ await expect(page.locator("#global-agent-provider")).toBeVisible();
+ await expect(page.getByTestId("agent-readiness-badge")).toHaveCount(0);
- // Take a screenshot of the entire setup page to capture the readiness badge.
await waitForAnimations(page);
- const setupPage = page.locator('[data-testid="onboarding-page-2"]');
- await setupPage.screenshot({
- path: `${SHOTS}/04-setup-readiness-badge.png`,
+ const configPage = page.locator('[data-testid="onboarding-page-config"]');
+ await configPage.screenshot({
+ path: `${SHOTS}/04-config-defaults-form.png`,
});
});
-test("setup page shows Not configured badge when no CLI runtime or buzz-agent config", async ({
+test("config page shows configure-later hint when no CLI runtime or buzz-agent config", async ({
page,
}) => {
// Seed empty ACP runtimes so no CLI harness is available.
@@ -47,46 +54,22 @@ test("setup page shows Not configured badge when no CLI runtime or buzz-agent co
);
await page.goto("/");
- await navigateToSetupPage(page);
+ await navigateToConfigPage(page);
- const badge = page.getByTestId("agent-readiness-badge");
- await expect(badge).toBeVisible();
- await expect(badge).toContainText("Not configured");
-
- // Not-configured warning text should be visible.
+ // Not-configured hint text should be visible below the form.
await expect(
page.getByText("You can finish now and configure agents later in Settings"),
).toBeVisible();
// Take a screenshot showing the not-configured state.
await waitForAnimations(page);
- const setupPage = page.locator('[data-testid="onboarding-page-2"]');
- await setupPage.screenshot({
+ const configPage = page.locator('[data-testid="onboarding-page-config"]');
+ await configPage.screenshot({
path: `${SHOTS}/05-setup-not-configured.png`,
});
});
-test("setup page Re-check button triggers runtimes refetch", async ({
- page,
-}) => {
- await installMockBridge(page, undefined, {
- skipCommunitySeed: true,
- skipOnboardingSeed: true,
- });
- await page.goto("/");
-
- await navigateToSetupPage(page);
-
- const recheckBtn = page.getByTestId("agent-readiness-recheck");
- await expect(recheckBtn).toBeVisible();
- await expect(recheckBtn).toBeEnabled();
- await recheckBtn.click();
-
- // After click the button should still be there (page stays on setup).
- await expect(recheckBtn).toBeVisible();
-});
-
-test("Finish button is always enabled on setup page regardless of readiness", async ({
+test("Finish button is always enabled on config page regardless of readiness", async ({
page,
}) => {
await installMockBridge(
@@ -96,7 +79,7 @@ test("Finish button is always enabled on setup page regardless of readiness", as
);
await page.goto("/");
- await navigateToSetupPage(page);
+ await navigateToConfigPage(page);
const finishBtn = page.getByTestId("onboarding-finish");
await expect(finishBtn).toBeVisible();
@@ -107,6 +90,52 @@ test("Finish button is always enabled on setup page regardless of readiness", as
// B1 regression: rapid consecutive edits must not lose the later change
// ---------------------------------------------------------------------------
+test("provider credentials are first-class and drive model discovery", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { acpRuntimesCatalog: undefined },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await navigateToConfigPage(page);
+
+ await page.locator("#global-agent-provider").selectOption("openai");
+ const apiKey = page.getByLabel("OpenAI API Key");
+ await expect(apiKey).toBeVisible();
+ await apiKey.fill("test-openai-key");
+ await expect(
+ page
+ .locator("#global-agent-model")
+ .getByRole("option", { name: "GPT-5.5" }),
+ ).toBeAttached();
+
+ await page.locator("#global-agent-provider").selectOption("openai-compat");
+ await expect(page.getByLabel("OpenAI API Key")).toHaveValue(
+ "test-openai-key",
+ );
+
+ await page
+ .locator("#global-agent-provider")
+ .selectOption("__custom_provider__");
+ await expect(page.getByLabel("OpenAI API Key")).not.toBeVisible();
+ await expect(page.locator('input[value="test-openai-key"]')).toHaveCount(0);
+
+ await page.locator("#global-agent-provider").selectOption("anthropic");
+ await expect(page.getByLabel("Anthropic API Key")).toBeVisible();
+
+ const databricksOption = page
+ .locator("#global-agent-provider")
+ .locator('option[value^="databricks"]')
+ .first();
+ await page
+ .locator("#global-agent-provider")
+ .selectOption(await databricksOption.getAttribute("value"));
+ await expect(page.getByLabel("Value for DATABRICKS_HOST")).toBeVisible();
+ await expect(page.getByLabel("OpenAI API Key")).not.toBeVisible();
+});
+
test("rapid consecutive provider changes both survive — later change wins", async ({
page,
}) => {
@@ -119,7 +148,7 @@ test("rapid consecutive provider changes both survive — later change wins", as
);
await page.goto("/");
- await navigateToSetupPage(page);
+ await navigateToConfigPage(page);
const providerSelect = page.locator("#global-agent-provider");
await expect(providerSelect).toBeVisible();
diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
index 6ee08154ad..2a6942f518 100644
--- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
+++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
@@ -31,10 +31,11 @@ test("avatar step always shows Skip for now button without an error", async ({
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
- // Capture a screenshot showing the avatar step with the skip button.
+ // Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
+ // docked footer (a sibling of the step subtree), so a section-scoped shot
+ // would omit the very buttons this artifact is meant to show.
await waitForAnimations(page);
- const avatarSection = page.locator('[data-testid="onboarding-page-avatar"]');
- await avatarSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/01-avatar-skip-button.png`,
});
});
diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts
index 83d125fbb2..a52f8870b6 100644
--- a/desktop/tests/e2e/onboarding-backup.spec.ts
+++ b/desktop/tests/e2e/onboarding-backup.spec.ts
@@ -38,10 +38,10 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
- // Take a screenshot of the masked state.
+ // Take a screenshot of the masked state. Capture the whole viewport: the CTAs
+ // are portaled into the docked footer outside the step subtree.
await waitForAnimations(page);
- const backupSection = page.locator('[data-testid="onboarding-page-backup"]');
- await backupSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/02-backup-step-masked.png`,
});
@@ -52,7 +52,7 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
// Take a screenshot of the revealed state.
await waitForAnimations(page);
- await backupSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/03-backup-step-revealed.png`,
});
});
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
new file mode 100644
index 0000000000..61bbb8e25f
--- /dev/null
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "@playwright/test";
+
+import { waitForAnimations } from "../helpers/animations";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import { seedActiveIdentity } from "../helpers/onboarding";
+
+const BLANK_TYLER_IDENTITY = {
+ ...TEST_IDENTITIES.tyler,
+ username: "",
+};
+
+const SHOT_DIR = "test-results/onboarding-docked-cta";
+
+test.use({ viewport: { width: 1280, height: 800 } });
+
+test("machine onboarding: landing, backup, setup docked CTAs", async ({
+ page,
+}) => {
+ await installMockBridge(page, undefined, {
+ skipCommunitySeed: true,
+ skipOnboardingSeed: true,
+ });
+ await page.goto("/");
+
+ const gate = page.getByTestId("machine-onboarding-gate");
+ await expect(gate).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
+
+ await page.getByRole("button", { name: "Get started" }).click();
+ await expect(
+ page.getByRole("heading", {
+ name: "Your unique identity has been created",
+ }),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
+
+ // Reveal the key: box must not reflow (same-length monospace mask).
+ await page.getByTestId("nsec-reveal-toggle").click();
+ await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(
+ page.getByRole("heading", { name: "Use the models that fit the task" }),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
+});
+
+test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
+ await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page
+ .getByTestId("onboarding-avatar-url")
+ .fill("https://example.com/onboarding-avatar.png");
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
+});
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index bdb4dc4ede..39a720c923 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -246,22 +246,25 @@ async function expectWelcomePersonaMention(page: Page) {
expect(alignment.display).toBe("inline-block");
expect(alignment.verticalAlign).toBe("baseline");
expect(alignment.lineHeightMatchesBanner).toBe(true);
- await expect(banner).toContainText("Try mentioning");
+ await expect(banner).toContainText("Mention");
+}
+
+async function expectPrivateWelcomeLanding(page: Page) {
+ await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
+ await expect(page.getByTestId("channel-Welcome")).toBeVisible();
+ await expect(page.getByTestId("chat-title")).toContainText("Welcome");
}
async function expectWelcomeView(page: Page) {
- await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
+ await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
- await expect(page.getByTestId("chat-title")).toContainText(
- "welcome-everyone",
- );
await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0);
await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
- "This is the beginning of the regular channel.",
+ "private welcome channel",
);
await expect(
page.getByTestId("message-channel-intro").getByRole("button"),
@@ -313,10 +316,10 @@ async function expectWelcomeView(page: Page) {
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
- "Try mentioning",
+ "Mention",
);
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
- "to chat with an agent in this channel.",
+ "whenever you want their help.",
);
await expectWelcomePersonaMention(page);
await expectWelcomeComposerBannerLayout(page);
@@ -419,7 +422,7 @@ async function getWelcomeChannelId(page: Page) {
return (
channels.find(
(channel) =>
- channel.name === "welcome-everyone" && channel.visibility === "open",
+ channel.name === "Welcome" && channel.visibility === "private",
)?.id ?? null
);
}
@@ -474,19 +477,13 @@ async function expectWelcomeGuideIntro(
return null;
}
- const [members, agents, introSearch] = await Promise.all([
+ const [members, agents] = await Promise.all([
invokeMockCommand<{
members: Array<{ pubkey: string; role: string; is_agent: boolean }>;
}>(page, "get_channel_members", { channelId }),
invokeMockCommand<
Array<{ pubkey: string; name: string; persona_id: string | null }>
>(page, "list_managed_agents"),
- invokeMockCommand<{
- hits: Array<{ pubkey: string; content: string }>;
- }>(page, "search_messages", {
- q: "Hi, I'm Fizz",
- limit: 10,
- }),
]);
const fizz = agents.find(
(agent) => agent.name === "Fizz" && agent.persona_id === "builtin:fizz",
@@ -494,12 +491,6 @@ async function expectWelcomeGuideIntro(
const fizzMember = fizz
? members.members.find((member) => member.pubkey === fizz.pubkey)
: null;
- const intro = fizz
- ? introSearch.hits.find((hit) => hit.pubkey === fizz.pubkey)
- : null;
- const fizzWelcomeHits = fizz
- ? introSearch.hits.filter((hit) => hit.pubkey === fizz.pubkey)
- : [];
const profileAvatarUrl = fizz
? (
await invokeMockCommand<{
@@ -513,32 +504,17 @@ async function expectWelcomeGuideIntro(
return {
fizzIsBot: fizzMember?.role === "bot" && fizzMember.is_agent,
fizzPersonaId: fizz?.persona_id ?? null,
- introContent: intro?.content ?? null,
- introMatchesFizz: Boolean(fizz && intro?.pubkey === fizz.pubkey),
- fizzWelcomeHitCount: fizzWelcomeHits.length,
profileAvatarUrl,
};
})
.toEqual({
fizzIsBot: true,
fizzPersonaId: "builtin:fizz",
- introContent:
- "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.",
- introMatchesFizz: true,
- fizzWelcomeHitCount: 1,
profileAvatarUrl: null,
});
if (expectVisible) {
- await expect(page.getByTestId("message-timeline")).toContainText(
- "Hi, I'm Fizz. Welcome to Buzz.",
- );
- await expect(page.getByTestId("message-timeline")).toContainText(
- "Feel free to ask me what else you can do in Buzz",
- );
- await expect(
- page.getByTestId("message-timeline-day-divider"),
- ).toBeVisible();
+ await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("system-message-row")).toHaveCount(0);
}
}
@@ -943,7 +919,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
-test("first-run onboarding keeps the shell hidden and lands on welcome-everyone after profile setup", async ({
+test("first-run onboarding keeps the shell hidden and lands on private Welcome after profile setup", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
@@ -974,70 +950,58 @@ async function retryToastAction(
{ command, title }: { command: string; title: string },
) {
const activeToast = retryToast(page, title);
- await expect(activeToast).toBeVisible();
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
+ const before = await commandCount(page, command);
+ await activeToast
+ .getByRole("button", { name: "Retry" })
+ .dispatchEvent("click");
+ await expect.poll(() => commandCount(page, command)).toBeGreaterThan(before);
+}
- const commandCountBeforeRetry = await page.evaluate(
- (retryCommand) =>
- (
- window as Window & {
- __BUZZ_E2E_COMMANDS__?: string[];
- }
- ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
+async function commandCount(page: Page, command: string) {
+ return page.evaluate(
+ (target) =>
+ window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target)
.length ?? 0,
command,
);
- await activeToast
- .getByRole("button", { name: "Retry" })
- .evaluate((button) => (button as HTMLButtonElement).click());
-
- await expect
- .poll(() =>
- page.evaluate(
- ({ retryCommand }) =>
- (
- window as Window & {
- __BUZZ_E2E_COMMANDS__?: string[];
- }
- ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
- .length ?? 0,
- { retryCommand: command, minimum: commandCountBeforeRetry + 1 },
- ),
- )
- .toBeGreaterThanOrEqual(commandCountBeforeRetry + 1);
-}
-
-async function expectRetryFailureRecreatesActionableToast(
- page: Page,
- { command, error, title }: { command: string; error: string; title: string },
-) {
- const activeToast = retryToast(page, title);
- await expect(activeToast).toContainText(error);
-
- await retryToastAction(page, { command, title });
-
- await expect(activeToast).toHaveCount(1);
- await expect(activeToast).toContainText(error);
- await expect(
- activeToast.getByRole("button", { name: "Retry" }),
- ).toBeVisible();
-}
-
-async function expectRetrySuccessDismissesToast(
- page: Page,
- { command, title }: { command: string; title: string },
-) {
- const activeToast = retryToast(page, title);
-
- await retryToastAction(page, { command, title });
-
- await expect(activeToast).toHaveCount(0);
}
test("failed starter channel retries recreate actionable toasts", async ({
page,
+}) => {
+ const starterError = "Mock starter channel setup failed.";
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ ensureStarterChannelsErrors: [starterError, starterError, starterError],
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await completeProfileOnboarding(page);
+
+ await expectPrivateWelcomeLanding(page);
+ const title = "Couldn't set up starter channels";
+ const activeToast = retryToast(page, title);
+ await expect(activeToast).toContainText(starterError);
+ await retryToastAction(page, {
+ command: "ensure_starter_channels",
+ title,
+ });
+ await expect(activeToast).toContainText(starterError);
+ await expect(
+ activeToast.getByRole("button", { name: "Retry" }),
+ ).toBeVisible();
+});
+
+test("successful starter channel retry clears its actionable toast", async ({
+ page,
}) => {
const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -1053,22 +1017,27 @@ test("failed starter channel retries recreate actionable toasts", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- await expectRetryFailureRecreatesActionableToast(page, {
+ const title = "Couldn't set up starter channels";
+ await expect(retryToast(page, title)).toContainText(starterError);
+ await retryToastAction(page, {
command: "ensure_starter_channels",
- error: starterError,
- title: "Couldn't set up starter channels",
+ title,
});
+ await expect(retryToast(page, title)).toHaveCount(0);
+ await expectWelcomeView(page);
+ await expectStarterChannels(page);
});
-test("successful starter channel retry clears its actionable toast", async ({
- page,
-}) => {
- const starterError = "Mock starter channel setup failed.";
+test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
- ensureStarterChannelsErrors: [starterError],
+ globalAgentConfig: {
+ env_vars: { OPENAI_API_KEY: "e2e-placeholder" },
+ provider: "openai",
+ model: "gpt-5.5",
+ },
},
{ skipOnboardingSeed: true },
);
@@ -1077,21 +1046,22 @@ test("successful starter channel retry clears its actionable toast", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- await expectRetrySuccessDismissesToast(page, {
- command: "ensure_starter_channels",
- title: "Couldn't set up starter channels",
- });
- await expectWelcomeView(page);
- await expect(page.getByTestId("channel-general")).toBeVisible();
+ await expectPrivateWelcomeLanding(page);
+ await expect(page.getByTestId("message-timeline")).toContainText(
+ "Hi, I'm Fizz. Welcome to Buzz.",
+ );
+ await expect(page.getByTestId("message-timeline")).toContainText(
+ "Honey and Bumble, introduce yourselves",
+ );
});
-test("first-run onboarding shows setup loading until starter channel bootstrap completes", async ({
+test("first-run onboarding lands before Welcome team bootstrap completes", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
- { createManagedAgentDelayMs: 9_000 },
+ { createManagedAgentDelayMs: 1_000 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
@@ -1099,69 +1069,13 @@ test("first-run onboarding shows setup loading until starter channel bootstrap c
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- const loadingGate = page.getByTestId("app-loading-gate");
- await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
- await expect(loadingGate).toBeVisible();
- await expect(loadingGate).toContainText("Setting up your community...");
-
- // The boot gate is the theme-adaptive grainient with the flapping Buzz bee
- // as its hero. The mark must paint complete on the FIRST frame — a blank
- // gate reads as "nothing is loading" — so nothing about it may depend on
- // SMIL/scripted animation ( count stays 0); the wing flap is pure
- // CSS on HTML-level wing layers so it keeps running on the compositor even
- // while boot work hogs the main thread.
- await expect(
- loadingGate.getByTestId("setup-grainient-background"),
- ).toBeVisible();
- const mark = loadingGate.locator(".buzz-mark");
- await expect(mark).toBeVisible();
- const gateTreatment = await loadingGate.evaluate((element) => {
- const markElement = element.querySelector(".buzz-mark");
- const markSvgs = markElement
- ? Array.from(markElement.querySelectorAll("svg"))
- : [];
- const wing = element.querySelector(".bee-wing");
- const wingStyles =
- wing instanceof SVGElement ? window.getComputedStyle(wing) : null;
- const wash = element.querySelector(".buzz-setup-grainient__wash");
- const washStyles =
- wash instanceof HTMLElement ? window.getComputedStyle(wash) : null;
- return {
- animateElementCount: element.querySelectorAll("animate").length,
- // The document must match the OS scheme before the gate mounts (inline
- //