diff --git a/AGENTS.md b/AGENTS.md
index 3a79c4b38d..e10e49e664 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -476,11 +476,18 @@ are frozen.**
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
px:
-- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
- text === `text-base` (16px) — chat is the app's base type size**, and the
- surrounding timeline elements (timestamps, system rows, code, reactions) are
- deliberate steps on that same stock ramp.
-- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
+- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general
+ interface text. All of these derive from the virtual typography rem and
+ therefore follow the user's font-size preference and Cmd +/- zoom.
+- ✅ Conversation text uses the named `text-message` token. Its
+ **Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard
+ zoom. Author names use the same conversation-size step; timestamps, system
+ rows, code, and reactions are deliberate neighboring steps on the shared
+ virtual-rem ramp. Keep those relationships tokenized rather than restoring a
+ fixed 16px chat baseline or hardcoding preference-specific values in
+ components.
+- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs`
+ (0.5rem / 8px at a 16px virtual rem) meta-text
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index fa3a565d49..b6d5a21dcb 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -2244,6 +2244,28 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option Option {
+ let arr = result["configOptions"].as_array()?;
+ for opt in arr {
+ if opt.get("category").and_then(|c| c.as_str()) == Some("thought_level") {
+ let config_id = opt
+ .get("configId")
+ .or_else(|| opt.get("id"))
+ .and_then(|v| v.as_str())?;
+ return Some(config_id.to_string());
+ }
+ }
+ None
+}
+
/// Match a desired model ID against a fresh `session/new` response.
///
/// Returns the correct ACP method to call, or `None` if no match.
@@ -2868,6 +2890,54 @@ mod tests {
assert!(super::extract_model_state(&result).is_none());
}
+ #[test]
+ fn extract_thought_level_config_id_finds_config_id() {
+ let result = serde_json::json!({
+ "sessionId": "sess-1",
+ "configOptions": [
+ { "configId": "model", "category": "model" },
+ {
+ "configId": "effort",
+ "category": "thought_level",
+ "options": [{ "value": "high" }, { "value": "low" }]
+ }
+ ]
+ });
+ assert_eq!(
+ super::extract_thought_level_config_id(&result).as_deref(),
+ Some("effort")
+ );
+ }
+
+ #[test]
+ fn extract_thought_level_config_id_falls_back_to_id_key() {
+ let result = serde_json::json!({
+ "configOptions": [
+ { "id": "effort", "category": "thought_level" }
+ ]
+ });
+ assert_eq!(
+ super::extract_thought_level_config_id(&result).as_deref(),
+ Some("effort")
+ );
+ }
+
+ #[test]
+ fn extract_thought_level_config_id_none_without_category() {
+ let result = serde_json::json!({
+ "configOptions": [
+ { "configId": "model", "category": "model" }
+ ]
+ });
+ assert!(super::extract_thought_level_config_id(&result).is_none());
+ }
+
+ #[test]
+ fn extract_thought_level_config_id_none_without_config_options() {
+ let result = serde_json::json!({ "sessionId": "sess-1" });
+ assert!(super::extract_thought_level_config_id(&result).is_none());
+ }
+
#[test]
fn resolve_prefers_stable_over_unstable() {
let result = serde_json::json!({
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index f9e7bf1ed8..5244ef5537 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -124,6 +124,11 @@ pub enum PermissionMode {
/// Agent default — permission requests per tool call.
#[value(alias = "default")]
Default,
+ /// Auto mode — fully autonomous execution; model-gated (requires a model
+ /// that supports `supportsAutoMode`). Degrades gracefully to `default`
+ /// when the session's active model does not support it.
+ #[value(alias = "auto")]
+ Auto,
/// Auto-approve file edits, still ask for other tools.
#[value(alias = "acceptEdits")]
AcceptEdits,
@@ -144,6 +149,7 @@ impl PermissionMode {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::Default => "default",
+ Self::Auto => "auto",
Self::AcceptEdits => "acceptEdits",
Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
@@ -423,6 +429,14 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_MODEL")]
pub model: Option,
+ /// Persisted effort level value (e.g. "high", "medium", "low") to apply via
+ /// `session/set_config_option` at the first session creation. The configId is
+ /// resolved from the adapter's advertised `thought_level` capability — not
+ /// hardcoded. Non-fatal: if the adapter does not advertise `thought_level`,
+ /// the value is silently ignored and the persisted effort is not overwritten.
+ #[arg(long, env = "BUZZ_ACP_EFFORT_LEVEL")]
+ pub effort_level: Option,
+
/// Title for the agent's ACP sessions, passed out-of-band in `session/new`
/// `_meta`. Adapters that recognize it name the session after this value;
/// others ignore it. Never enters the prompt.
@@ -540,6 +554,12 @@ pub struct Config {
pub memory_enabled: bool,
/// Desired LLM model ID. Applied after every `session_new_full()`.
pub model: Option,
+ /// Persisted effort level value (e.g. "high", "medium", "low"). Held as a
+ /// per-worker spawn-scoped value and applied at the first session creation
+ /// by pairing with the adapter's advertised `thought_level` configId.
+ /// Non-fatal when absent or when the adapter does not advertise
+ /// `thought_level`.
+ pub effort_level: Option,
/// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`.
/// `None` when unset or when the configured value sanitized to empty.
pub session_title: Option,
@@ -1105,6 +1125,7 @@ impl Config {
typing_enabled: !args.no_typing,
memory_enabled: args.memory && !args.no_memory,
model,
+ effort_level: args.effort_level,
session_title: args
.session_title
.as_deref()
@@ -1480,6 +1501,7 @@ mod tests {
typing_enabled: true,
memory_enabled: true,
model: None,
+ effort_level: None,
session_title: None,
permission_mode: PermissionMode::BypassPermissions,
respond_to: RespondTo::Anyone,
@@ -2298,6 +2320,7 @@ channels = "ALL"
#[test]
fn test_permission_mode_wire_strings() {
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
+ assert_eq!(PermissionMode::Auto.as_wire_str(), "auto");
assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits");
assert_eq!(
PermissionMode::BypassPermissions.as_wire_str(),
@@ -2310,12 +2333,24 @@ channels = "ALL"
#[test]
fn test_permission_mode_is_default() {
assert!(PermissionMode::Default.is_default());
+ assert!(!PermissionMode::Auto.is_default());
assert!(!PermissionMode::BypassPermissions.is_default());
assert!(!PermissionMode::AcceptEdits.is_default());
assert!(!PermissionMode::DontAsk.is_default());
assert!(!PermissionMode::Plan.is_default());
}
+ #[test]
+ fn test_permission_mode_auto_degrades_to_default_when_unsupported() {
+ // The wire string is "auto" — the adapter handles graceful downgrade
+ // to "default" when the active model does not support Auto mode.
+ // Verify only that the wire string is correct and distinct from "default".
+ let auto = PermissionMode::Auto;
+ assert_eq!(auto.as_wire_str(), "auto");
+ assert_ne!(auto.as_wire_str(), "default");
+ assert!(!auto.is_default());
+ }
+
#[test]
fn test_permission_mode_display() {
assert_eq!(
@@ -2323,6 +2358,7 @@ channels = "ALL"
"bypassPermissions"
);
assert_eq!(format!("{}", PermissionMode::Default), "default");
+ assert_eq!(format!("{}", PermissionMode::Auto), "auto");
}
#[test]
@@ -2360,6 +2396,7 @@ channels = "ALL"
use clap::ValueEnum;
let cases = [
("default", PermissionMode::Default),
+ ("auto", PermissionMode::Auto),
("accept-edits", PermissionMode::AcceptEdits),
("bypass-permissions", PermissionMode::BypassPermissions),
("dont-ask", PermissionMode::DontAsk),
@@ -2382,6 +2419,7 @@ channels = "ALL"
use clap::ValueEnum;
let cases = [
("default", PermissionMode::Default),
+ ("auto", PermissionMode::Auto),
("acceptEdits", PermissionMode::AcceptEdits),
("bypassPermissions", PermissionMode::BypassPermissions),
("dontAsk", PermissionMode::DontAsk),
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 0f5cce3b8f..af00e0c7c6 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1336,6 +1336,13 @@ fn handle_switch_model_control(
tracing::warn!("observer switch_model control frame missing modelId");
return;
};
+ // Opaque per-pick correlator, echoed on every result frame so the Desktop
+ // can ignore a replayed result for an earlier pick. Optional: absent on
+ // older Desktop clients, in which case the frames simply carry no id.
+ let request_id = payload
+ .get("requestId")
+ .and_then(|value| value.as_str())
+ .map(str::to_string);
// A turn is in flight for this channel iff a task_map entry exists. The
// agent is moved out of the pool during a turn, so the control oneshot is
@@ -1352,7 +1359,10 @@ fn handle_switch_model_control(
if signal_in_flight_task(
pool,
channel_id,
- ControlSignal::SwitchModel(model_id.to_string()),
+ ControlSignal::SwitchModel {
+ model_id: model_id.to_string(),
+ request_id: request_id.clone(),
+ },
) {
"sent"
} else {
@@ -1360,7 +1370,7 @@ fn handle_switch_model_control(
}
} else {
// Idle path: validate against the cached catalog before invalidating.
- match pool.switch_idle_agent_model(channel_id, model_id) {
+ match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) {
IdleSwitchResult::Switched => "switched",
IdleSwitchResult::UnsupportedModel => "unsupported_model",
IdleSwitchResult::NoIdleAgent => "no_active_turn",
@@ -1381,6 +1391,9 @@ fn handle_switch_model_control(
"type": "switch_model",
"status": status,
"modelId": model_id,
+ // Echo the correlator on the immediate ack so a `sent` /
+ // `turn_ending` / idle-path terminal frame matches the pick.
+ "requestId": request_id,
}),
);
}
@@ -2478,6 +2491,9 @@ async fn tokio_main() -> Result<()> {
model_capabilities: None,
desired_model: config.model.clone(),
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: config.effort_level.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
@@ -4706,6 +4722,7 @@ struct PoolStartup {
extra_env: Vec<(String, String)>,
has_generated_codex_config: bool,
model: Option,
+ effort_level: Option,
observer: Option,
}
@@ -4718,6 +4735,7 @@ impl PoolStartup {
extra_env: config.persona_env_vars.clone(),
has_generated_codex_config: config.has_generated_codex_config,
model: config.model.clone(),
+ effort_level: config.effort_level.clone(),
observer,
}
}
@@ -4785,6 +4803,9 @@ async fn initialize_agent_pool(
model_capabilities: None,
desired_model: startup.model.clone(),
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: startup.effort_level.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
@@ -7144,6 +7165,7 @@ mod build_mcp_servers_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
+ effort_level: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
@@ -7367,6 +7389,7 @@ mod error_outcome_emission_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
+ effort_level: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
@@ -7413,6 +7436,9 @@ mod error_outcome_emission_tests {
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
// Error branches under test never read this; 1 is the legacy
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 5e1e960e0d..42492de4ae 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -30,9 +30,9 @@ use tokio::time::timeout;
use uuid::Uuid;
use crate::acp::{
- extract_model_config_options, extract_model_state, model_in_catalog,
- resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod,
- StopReason, SystemPromptTransport,
+ extract_model_config_options, extract_model_state, extract_thought_level_config_id,
+ model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer,
+ ModelSwitchMethod, StopReason, SystemPromptTransport,
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::observer;
@@ -88,6 +88,12 @@ pub struct AgentModelCapabilities {
pub config_options_raw: Vec,
/// Unstable: SessionModelState from session/new.
pub available_models_raw: Option,
+ /// B5: configId for the `thought_level` category option, if the adapter
+ /// advertised one in session/new. Resolved at session time so the
+ /// spawn-scoped effort application forwards the adapter's real configId
+ /// instead of hardcoding it. `None` when the adapter advertises no
+ /// `thought_level` option.
+ pub thought_level_config_id: Option,
}
/// Successful deliveries associated with one live channel session.
@@ -203,6 +209,28 @@ pub struct OwnedAgent {
/// desktop reader to distinguish a genuine runtime override from a stale
/// session whose persona model was edited. Reset on spawn/restart.
pub model_overridden: bool,
+ /// Opaque per-pick `request_id` from the live `SwitchModel` that set
+ /// `desired_model`, echoed on the late `control_result` frame so the
+ /// Desktop ModelPicker can correlate it to the pick that fired the switch.
+ /// `None` for config/persona-derived models (no live pick to correlate).
+ pub desired_model_request_id: Option,
+ /// True when a busy-path live switch is awaiting its deferred apply: the
+ /// switch was delivered to an in-flight turn (`sent` ack), the turn was
+ /// cancelled+requeued, and the real apply runs at the next session. On that
+ /// apply, `create_session_and_apply_model` emits a positive terminal
+ /// `control_result` (success) so the Desktop learns the outcome instead of
+ /// inferring it from timeout silence. The idle path never sets this — it
+ /// already emits its terminal immediately — so this gate prevents a
+ /// double-emit there. Consumed (reset) at apply time.
+ pub desired_model_pending_ack: bool,
+ /// Persisted startup effort value from `BUZZ_ACP_EFFORT_LEVEL` (carried from
+ /// the Desktop record via `Config.effort_level`). Held per-worker and applied
+ /// once, at the first session creation, by pairing with the adapter's
+ /// advertised `thought_level` configId. This is spawn-scoped only — there is
+ /// no pool-level effort state and no live mid-conversation effort switching.
+ /// Non-fatal when absent or when the adapter does not advertise
+ /// `thought_level`.
+ pub startup_effort: Option,
/// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`).
pub agent_name: String,
/// Whether Goose accepted its custom system-prompt method. `None` probes on
@@ -304,7 +332,7 @@ fn apply_completed_before_control_signal(
// the fresh session applies the new model on its next creation.
if matches!(
control_signal,
- ControlSignal::Rotate | ControlSignal::SwitchModel(_)
+ ControlSignal::Rotate | ControlSignal::SwitchModel { .. }
) {
state.invalidate(source);
}
@@ -312,7 +340,7 @@ fn apply_completed_before_control_signal(
/// Control signal for an in-flight channel turn.
///
-/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when
+/// Not `Copy`: `SwitchModel` carries owned `String`s. Callers must clone when
/// a value is needed after a move, or match by reference.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ControlSignal {
@@ -335,7 +363,14 @@ pub enum ControlSignal {
/// setting `OwnedAgent::desired_model` before invalidation; the requeued
/// turn re-creates the session and re-applies `desired_model`. Runtime-only
/// — never persisted, gone on restart/respawn.
- SwitchModel(String),
+ ///
+ /// Carries `(model_id, request_id)`: the opaque per-pick `request_id`
+ /// originates in the Desktop ModelPicker and is echoed on every
+ /// `control_result` frame so a replayed result cannot settle a later pick.
+ SwitchModel {
+ model_id: String,
+ request_id: Option,
+ },
}
/// Goose-native non-cancelling steer request, sent from the main loop to an
@@ -844,6 +879,7 @@ impl AgentPool {
&mut self,
channel_id: Uuid,
model_id: &str,
+ request_id: Option,
) -> IdleSwitchResult {
let Some(agent) = self
.agents
@@ -868,6 +904,9 @@ impl AgentPool {
agent.desired_model = Some(model_id.to_string());
agent.model_overridden = true;
+ // Carry the pick's correlator so a deferred-validation miss on the next
+ // turn's session creation emits a late frame the Desktop can match.
+ agent.desired_model_request_id = request_id;
agent.state.invalidate_channel(&channel_id);
IdleSwitchResult::Switched
}
@@ -1046,17 +1085,94 @@ async fn create_session_and_apply_model(
agent.model_capabilities = Some(AgentModelCapabilities {
config_options_raw: extract_model_config_options(&resp.raw),
available_models_raw: extract_model_state(&resp.raw),
+ thought_level_config_id: extract_thought_level_config_id(&resp.raw),
});
}
- // Apply desired_model if set, matching against the fresh session/new response.
- // Track whether the switch succeeded so session_config_captured reflects
- // the post-switch state (not the pre-switch desired state).
- let switch_succeeded = if let Some(ref desired) = agent.desired_model {
+ // Apply desired_model if set, matching against the fresh session/new
+ // response. `post_switch_snapshot` drives everything downstream:
+ // `Some(value)` → a switch applied; `value` is the adapter's post-switch
+ // RPC response, whose `configOptions` describe the target
+ // model. Effort resolution and the Desktop capture both
+ // read it so they converge on the model the session is
+ // actually running, not the pre-switch default.
+ // `None` → no switch, or the adapter rejected/does-not-know the
+ // model; the session/new snapshot is cached as-is and
+ // `switch_succeeded` stays false.
+ let post_switch_snapshot: Option = if let Some(ref desired) =
+ agent.desired_model
+ {
+ // Consume the busy-path pending-ack once for this apply: only the
+ // `Applied` arm turns it into a positive terminal; the rejection and
+ // unsupported arms already emit their own correlated failure frame, so
+ // taking it here keeps a leftover flag from firing a spurious success
+ // on some later unrelated session.
+ let pending_ack = std::mem::take(&mut agent.desired_model_pending_ack);
match resolve_model_switch_method(&resp.raw, desired) {
Some(method) => {
- apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?;
- true
+ match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?
+ {
+ ModelSwitchOutcome::Applied(switch_result) => {
+ // The adapter rebuilds `session.configOptions` for the
+ // target model and echoes them here. Refresh capabilities
+ // from that authoritative snapshot when present so the
+ // idle-switch guard and the panel reflect the target
+ // model; drop to `None` (re-derive next session) when the
+ // adapter returned no options so a pre-switch snapshot is
+ // never mistaken for the target model's.
+ if switch_result
+ .get("configOptions")
+ .is_some_and(|v| !v.is_null())
+ {
+ agent.model_capabilities = Some(AgentModelCapabilities {
+ config_options_raw: extract_model_config_options(&switch_result),
+ available_models_raw: extract_model_state(&switch_result),
+ thought_level_config_id: extract_thought_level_config_id(
+ &switch_result,
+ ),
+ });
+ } else {
+ agent.model_capabilities = None;
+ }
+ // Busy-path deferred switch: emit a positive terminal so
+ // the Desktop confirms success from a real frame instead
+ // of inferring it from timeout silence. Gated on the
+ // pending-ack flag so the idle path (which already acked
+ // `switched` immediately) does not double-emit.
+ if pending_ack {
+ agent.acp.observe(
+ "control_result",
+ serde_json::json!({
+ "type": "switch_model",
+ "status": "switched",
+ "modelId": desired,
+ "requestId": agent.desired_model_request_id,
+ }),
+ );
+ }
+ Some(switch_result)
+ }
+ ModelSwitchOutcome::Rejected => {
+ // The adapter explicitly rejected the switch: the session
+ // is still on its default model. Surface a terminal
+ // failure so the Desktop ModelPicker rejects the live pick
+ // instead of falsely reporting success, and preserve the
+ // pre-switch capabilities the session is really running.
+ agent.acp.observe(
+ "control_result",
+ serde_json::json!({
+ "type": "switch_model",
+ "status": "failure",
+ "modelId": desired,
+ // Echo the pick's request_id so the Desktop can
+ // correlate this late frame to the operation
+ // that fired it, and ignore replayed results.
+ "requestId": agent.desired_model_request_id,
+ }),
+ );
+ None
+ }
+ }
}
None => {
tracing::warn!(
@@ -1073,26 +1189,64 @@ async fn create_session_and_apply_model(
"type": "switch_model",
"status": "unsupported_model",
"modelId": desired,
+ // Echo the pick's request_id (see the failure arm).
+ "requestId": agent.desired_model_request_id,
}),
);
- false
+ None
}
}
} else {
- false
+ None
};
+ let switch_succeeded = post_switch_snapshot.is_some();
+
+ // Apply the worker's spawn-scoped startup effort, if configured and the
+ // running model advertises a `thought_level` option. Runs on every session
+ // creation (config options are per-session), mirroring the model-switch
+ // application above. The held value comes from `BUZZ_ACP_EFFORT_LEVEL` and
+ // never mutates — there is no pool-level effort state and no live switching.
+ // Reads the post-switch snapshot so the configId is discovered on the model
+ // the session is actually running; computed BEFORE the capture emission so
+ // the cached configOptions tell the truth about the running session.
+ let effort_snapshot = post_switch_snapshot.as_ref().unwrap_or(&resp.raw);
+ let effort_outcome = apply_startup_effort(agent, effort_snapshot, &resp.session_id).await?;
// Emit session config for desktop consumption (config bridge tier 1b).
// Emitted AFTER desired_model resolution so the desktop caches the
// post-switch state. modelOverridden reflects whether the switch actually
- // applied — false on the unsupported arm so the panel doesn't show a
- // stale override badge.
+ // applied — false on the rejected/unsupported arms so the panel doesn't show
+ // a stale override badge.
+ //
+ // configOptions come from the post-switch snapshot on a successful switch
+ // (the target model's option set) and the session/new snapshot otherwise.
+ // Truthful capture: after a successful effort application the snapshot still
+ // carries the pre-set `currentValue`, so patch the applied option to the
+ // value the session is actually running. A rejected effort or a model with
+ // no `thought_level` option leaves the snapshot untouched.
+ let config_options_for_cache = {
+ let mut opts = effort_snapshot
+ .get("configOptions")
+ .cloned()
+ .unwrap_or(serde_json::Value::Null);
+ if let Some(StartupEffortOutcome::Applied { config_id, value }) = &effort_outcome {
+ patch_config_option_current_value(&mut opts, config_id, value);
+ }
+ opts
+ };
agent.acp.observe(
"session_config_captured",
serde_json::json!({
- "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null),
+ "configOptions": config_options_for_cache,
"modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null),
- "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null),
+ // `models` must come from the SAME snapshot as configOptions — the
+ // post-switch snapshot on a successful switch, session/new otherwise.
+ // Taking it from `resp.raw` here would emit the target model's option
+ // set alongside the pre-switch model identity, so the desktop panel
+ // would report the old model as live after an applied switch. When a
+ // successful target response omits `models`, this emits Null rather
+ // than falling back to the pre-switch `resp.raw.models`.
+ "models": effort_snapshot.get("models").cloned().unwrap_or(serde_json::Value::Null),
"modelOverridden": agent.model_overridden && switch_succeeded,
// Pair identity for the desktop session-config cache, which is
// keyed by (agent, relay) like the lifecycle frames.
@@ -1141,18 +1295,35 @@ fn mcp_servers_with_git_origin(
servers
}
+/// Outcome of a live model-switch RPC returned by [`apply_model_switch`].
+///
+/// `Applied` and `Rejected` are distinct outcomes and must not be collapsed:
+/// the caller needs to know whether the session is now on the target model
+/// before deciding what capabilities to cache and whether to surface a failure.
+#[derive(Debug)]
+enum ModelSwitchOutcome {
+ /// The adapter accepted the switch. Carries the RPC response value, which
+ /// may include refreshed `configOptions` for the target model.
+ Applied(serde_json::Value),
+ /// The adapter returned an application-level error (e.g. JSON error,
+ /// unrecognised model). The session is still on its default model;
+ /// pre-switch capabilities must be preserved.
+ Rejected,
+}
+
/// Send the appropriate ACP model-switch request with a timeout.
///
-/// On timeout or error, logs a warning and returns — the caller proceeds
-/// with the agent's default model. This is intentionally non-fatal: a stale
-/// response from a timed-out request is safely ignored by `read_until_response`
-/// (non-matching JSON-RPC IDs are skipped).
+/// Transport-class errors propagate as `Err` so the caller respawns the agent
+/// rather than reuse a poisoned stdio stream. An application-level rejection is
+/// non-fatal but distinct from success: it returns [`ModelSwitchOutcome::Rejected`]
+/// so the caller preserves pre-switch capabilities and tells Desktop the pick
+/// failed instead of silently claiming the switch landed.
async fn apply_model_switch(
acp: &mut AcpClient,
session_id: &str,
desired: &str,
method: &ModelSwitchMethod,
-) -> Result<(), AcpError> {
+) -> Result {
let method_label = match method {
ModelSwitchMethod::ConfigOption { config_id, .. } => {
format!("configOption (configId={config_id})")
@@ -1177,11 +1348,15 @@ async fn apply_model_switch(
.await;
match result {
- Ok(Ok(_)) => {
+ // Return the RPC result so the caller can consume the post-switch
+ // capability snapshot the adapter echoes (claude-agent-acp rebuilds
+ // `session.configOptions` on a model change and returns them here).
+ Ok(Ok(value)) => {
tracing::info!(
target: "pool::model",
"applied model {desired} via {method_label} on session {session_id}"
);
+ Ok(ModelSwitchOutcome::Applied(value))
}
// Transport-class errors may have corrupted the stdio stream — propagate
// so the caller can respawn the agent instead of reusing a poisoned one.
@@ -1194,14 +1369,18 @@ async fn apply_model_switch(
target: "pool::model",
"fatal error setting model {desired} via {method_label}: {e}"
);
- return Err(e);
+ Err(e)
}
- // Application-level errors (Json, etc.) — agent is fine, just uses default model.
+ // Application-level errors (Json, etc.) — the adapter explicitly
+ // rejected the switch; the session is still on its default model.
+ // Distinct from a successful switch that returned no configOptions:
+ // the caller must preserve pre-switch capabilities here.
Ok(Err(e)) => {
tracing::warn!(
target: "pool::model",
"failed to set model {desired} via {method_label}: {e} — proceeding with agent default"
);
+ Ok(ModelSwitchOutcome::Rejected)
}
Err(_) => {
// Outer timeout fired — the inner send_request may have left the
@@ -1210,10 +1389,123 @@ async fn apply_model_switch(
target: "pool::model",
"model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal"
);
- return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT));
+ Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT))
+ }
+ }
+}
+
+/// Outcome of applying a worker's spawn-scoped startup effort at session creation.
+///
+/// Drives truthful capture: only `Applied` patches the cached `currentValue`.
+/// `Rejected` (adapter refused) and the `None` return (model advertises no
+/// `thought_level` option, or no effort was configured) leave the session/new
+/// snapshot untouched so the panel reflects the session's real state.
+enum StartupEffortOutcome {
+ Applied { config_id: String, value: String },
+ Rejected,
+}
+
+/// Apply the worker's held `startup_effort` via `session/set_config_option`, if
+/// set and the current model advertises a `thought_level` option.
+///
+/// Returns `Ok(None)` when there is nothing to apply (no configured effort, or
+/// the model has no `thought_level` option) or `Ok(Some(_))` describing whether
+/// the adapter accepted the value. Transport-class errors propagate as `Err` so
+/// the caller respawns the worker rather than reuse a poisoned stream — mirroring
+/// [`apply_model_switch`]'s classification. Application-level rejection is
+/// non-fatal: the session proceeds on the model's default effort.
+async fn apply_startup_effort(
+ agent: &mut OwnedAgent,
+ session_new_result: &serde_json::Value,
+ session_id: &str,
+) -> Result, AcpError> {
+ let Some(value) = agent.startup_effort.clone() else {
+ return Ok(None);
+ };
+ let Some(config_id) = extract_thought_level_config_id(session_new_result) else {
+ tracing::info!(
+ target: "pool::effort",
+ "startup effort {value} configured but model advertises no thought_level option — leaving agent default"
+ );
+ return Ok(None);
+ };
+
+ let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async {
+ agent
+ .acp
+ .session_set_config_option(session_id, &config_id, &value)
+ .await
+ })
+ .await;
+
+ match result {
+ Ok(Ok(_)) => {
+ tracing::info!(
+ target: "pool::effort",
+ "applied startup effort {value} via configId={config_id} on session {session_id}"
+ );
+ Ok(Some(StartupEffortOutcome::Applied { config_id, value }))
+ }
+ // Transport-class errors may have corrupted the stdio stream — propagate
+ // so the caller can respawn the agent instead of reusing a poisoned one.
+ Ok(Err(e @ AcpError::Io(_)))
+ | Ok(Err(e @ AcpError::WriteTimeout(_)))
+ | Ok(Err(e @ AcpError::Timeout(_)))
+ | Ok(Err(e @ AcpError::Protocol(_)))
+ | Ok(Err(e @ AcpError::AgentExited)) => {
+ tracing::error!(
+ target: "pool::effort",
+ "fatal error applying startup effort {value} via configId={config_id}: {e}"
+ );
+ Err(e)
+ }
+ // Application-level rejection (e.g. Json) — agent is fine, uses default effort.
+ Ok(Err(e)) => {
+ tracing::warn!(
+ target: "pool::effort",
+ "adapter rejected startup effort {value} via configId={config_id}: {e} — proceeding with agent default"
+ );
+ Ok(Some(StartupEffortOutcome::Rejected))
+ }
+ Err(_) => {
+ // Outer timeout fired — the inner send_request may have left the
+ // stream in an unknown state. Treat as transport error.
+ tracing::error!(
+ target: "pool::effort",
+ "startup effort {value} via configId={config_id} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal"
+ );
+ Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT))
+ }
+ }
+}
+
+/// Patch the `currentValue` of the configOption whose `configId`/`id` matches
+/// `config_id` in a session/new `configOptions` array, in place.
+///
+/// Used by truthful capture: a successful `session/set_config_option` is not
+/// reflected in the original session/new snapshot, so the accepted value is
+/// written back before the snapshot is cached. A no-op when `options` is not an
+/// array or no entry matches (the id came from the same array, so a match is
+/// expected in practice).
+fn patch_config_option_current_value(
+ options: &mut serde_json::Value,
+ config_id: &str,
+ value: &str,
+) {
+ let Some(arr) = options.as_array_mut() else {
+ return;
+ };
+ for opt in arr {
+ let matches = opt
+ .get("configId")
+ .or_else(|| opt.get("id"))
+ .and_then(|v| v.as_str())
+ == Some(config_id);
+ if matches {
+ opt["currentValue"] = serde_json::Value::String(value.to_string());
+ return;
}
}
- Ok(())
}
/// Set the session permission mode via `session/set_config_option`.
@@ -2218,9 +2510,15 @@ pub async fn run_prompt_task(
// `desired_model` here means the fresh session created by the
// requeued turn (busy) or the next turn (already-completed)
// applies the new model. Runtime-only — never persisted.
- if let ControlSignal::SwitchModel(ref model_id) = control_signal {
+ if let ControlSignal::SwitchModel { model_id, request_id } = &control_signal {
agent.desired_model = Some(model_id.clone());
agent.model_overridden = true;
+ agent.desired_model_request_id = request_id.clone();
+ // Busy path: the real apply is deferred to the requeued
+ // session. Arm the positive-terminal emit so that apply
+ // reports success explicitly rather than the Desktop
+ // inferring it from timeout silence.
+ agent.desired_model_pending_ack = true;
}
// Control signal received. Guard against Race 1: the turn may
// have completed naturally just as cancel fired.
@@ -2311,7 +2609,7 @@ pub async fn run_prompt_task(
// MUST send a PromptResult or the main loop deadlocks.
if matches!(
control_signal,
- ControlSignal::Rotate | ControlSignal::SwitchModel(_)
+ ControlSignal::Rotate | ControlSignal::SwitchModel { .. }
) {
tracing::debug!(
target: "pool::prompt",
@@ -3693,7 +3991,7 @@ fn requeue_cancelled_batch(
) -> Option {
let reason = match signal {
ControlSignal::Steer => CancelReason::Steer,
- ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt,
+ ControlSignal::Interrupt | ControlSignal::SwitchModel { .. } => CancelReason::Interrupt,
// Cancel/Rotate discard the batch — no merged re-prompt.
ControlSignal::Cancel | ControlSignal::Rotate => return None,
};
@@ -4413,6 +4711,40 @@ mod tests {
}
}
+ // MINOR (#2884): the permission-mode RPC is gated on agent_supports_mode.
+ // An advertised mode issues set_config_option; an absent one is skipped so
+ // the harness falls back to per-tool auto-approval. Pin both edges directly.
+ #[test]
+ fn agent_supports_mode_advertised_auto_is_true() {
+ let session_new = json!({
+ "modes": { "availableModes": [{ "id": "default" }, { "id": "auto" }] }
+ });
+ assert!(agent_supports_mode(
+ &session_new,
+ PermissionMode::Auto.as_wire_str()
+ ));
+ }
+
+ #[test]
+ fn agent_supports_mode_absent_auto_is_false() {
+ let session_new = json!({
+ "modes": { "availableModes": [{ "id": "default" }] }
+ });
+ assert!(!agent_supports_mode(
+ &session_new,
+ PermissionMode::Auto.as_wire_str()
+ ));
+ }
+
+ #[test]
+ fn agent_supports_mode_missing_modes_field_is_false() {
+ let session_new = json!({ "sessionId": "sess-1" });
+ assert!(!agent_supports_mode(
+ &session_new,
+ PermissionMode::Auto.as_wire_str()
+ ));
+ }
+
#[test]
fn public_session_forwards_channel_origin_to_mcp() {
let channel_id = Uuid::new_v4();
@@ -5653,6 +5985,9 @@ done"#
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "legacy-test-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 1,
@@ -5747,6 +6082,9 @@ done"#
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "legacy-test-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 1,
@@ -5919,6 +6257,9 @@ done"#
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "legacy-test-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 1,
@@ -6069,6 +6410,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "legacy-test-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 1,
@@ -6482,7 +6826,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
apply_completed_before_control_signal(
&mut s,
&PromptSource::Channel(ch_a),
- &ControlSignal::SwitchModel("gpt-5".into()),
+ &ControlSignal::SwitchModel {
+ model_id: "gpt-5".into(),
+ request_id: None,
+ },
);
assert!(!s.has_channel_state(&ch_a));
@@ -6522,7 +6869,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
(ControlSignal::Steer, Some(CancelReason::Steer)),
(ControlSignal::Interrupt, Some(CancelReason::Interrupt)),
(
- ControlSignal::SwitchModel("gpt-5".into()),
+ ControlSignal::SwitchModel {
+ model_id: "gpt-5".into(),
+ request_id: None,
+ },
Some(CancelReason::Interrupt),
),
(ControlSignal::Cancel, None),
@@ -6638,7 +6988,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
Case {
name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason",
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
- signal: ControlSignal::SwitchModel("gpt-5".to_string()),
+ signal: ControlSignal::SwitchModel {
+ model_id: "gpt-5".to_string(),
+ request_id: None,
+ },
expected_outcome: "CancelDrainTimeout",
batch_preserved: true,
expected_reason: Some(CancelReason::Interrupt),
@@ -7056,6 +7409,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -7114,6 +7470,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
model_capabilities: None,
desired_model: None,
model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -7549,7 +7908,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
);
}
- fn make_prompt_context_no_owner() -> PromptContext {
+ pub(super) fn make_prompt_context_no_owner() -> PromptContext {
let agent_keys = nostr::Keys::generate();
make_prompt_context_impl(&agent_keys, None)
}
@@ -8141,3 +8500,805 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
server.abort();
}
}
+
+#[cfg(test)]
+mod startup_effort_tests {
+ use super::*;
+ use crate::acp::AcpClient;
+ use tests::make_prompt_context_no_owner;
+
+ /// Build a protocol-v2, non-goose agent whose only ACP requests will be
+ /// `session/new` (id 0) then the startup-effort `session/set_config_option`
+ /// (id 1). `startup_effort` is the held spawn-scoped value under test.
+ fn effort_agent(acp: AcpClient, startup_effort: Option<&str>) -> OwnedAgent {
+ OwnedAgent {
+ index: 0,
+ acp,
+ state: SessionState::default(),
+ model_capabilities: None,
+ desired_model: None,
+ model_overridden: false,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: startup_effort.map(str::to_string),
+ agent_name: "effort-test-agent".into(),
+ goose_system_prompt_supported: None,
+ protocol_version: 2,
+ }
+ }
+
+ /// Spawn a scripted ACP that answers `session/new` (request #1) with the
+ /// given configOptions, then replies to the effort `set_config_option`
+ /// (request #2) with `effort_reply` (a JSON-RPC `result`/`error` body, minus
+ /// the id which is filled in). Any later request gets `{"ok":true}`.
+ async fn spawn_effort_acp(session_new_config_options: &str, effort_reply: &str) -> AcpClient {
+ let script = format!(
+ r#"count=0
+while IFS= read -r line; do
+ count=$((count + 1))
+ id=$((count - 1))
+ if [ "$count" -eq 1 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_config_options}}}}}'
+ elif [ "$count" -eq 2 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{effort_reply}}}'
+ else
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}'
+ fi
+done"#
+ );
+ AcpClient::spawn("bash", &["-c".to_string(), script], &[], false)
+ .await
+ .expect("spawn effort ACP script")
+ }
+
+ fn captured_config_options(obs: &observer::ObserverHandle) -> serde_json::Value {
+ obs.snapshot()
+ .into_iter()
+ .find(|e| e.kind == "session_config_captured")
+ .expect("session_config_captured emitted")
+ .payload["configOptions"]
+ .clone()
+ }
+
+ fn effort_current_value(options: &serde_json::Value) -> Option {
+ options
+ .as_array()?
+ .iter()
+ .find(|o| o["category"] == "thought_level")
+ .and_then(|o| o["currentValue"].as_str())
+ .map(str::to_string)
+ }
+
+ const OPTS_WITH_EFFORT_DEFAULT_LOW: &str = r#"[{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#;
+
+ #[tokio::test]
+ async fn test_applied_effort_patches_captured_current_value_to_high() {
+ let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await;
+ let mut agent = effort_agent(acp, Some("high"));
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let opts = captured_config_options(&obs);
+ assert_eq!(
+ effort_current_value(&opts).as_deref(),
+ Some("high"),
+ "applied effort must overwrite the pre-set currentValue in the capture"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_rejected_effort_retains_captured_current_value() {
+ // Adapter answers the effort set with a JSON-RPC error → AgentError →
+ // application-level rejection: non-fatal, capture keeps the default.
+ let acp = spawn_effort_acp(
+ OPTS_WITH_EFFORT_DEFAULT_LOW,
+ r#""error":{"code":-32602,"message":"unsupported effort value"}"#,
+ )
+ .await;
+ let mut agent = effort_agent(acp, Some("high"));
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("rejection is non-fatal; session creation still succeeds");
+
+ let opts = captured_config_options(&obs);
+ assert_eq!(
+ effort_current_value(&opts).as_deref(),
+ Some("low"),
+ "a rejected effort must not falsify the capture — keep the running value"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_no_thought_level_model_leaves_capture_unpatched() {
+ // Model advertises only a `model` option — no thought_level. The held
+ // effort is silently ignored and no set_config_option is sent.
+ let opts_no_effort = r#"[{"configId":"model","category":"model","currentValue":"m-a","options":[{"value":"m-a"}]}]"#;
+ let acp = spawn_effort_acp(opts_no_effort, r#""result":{"ok":true}"#).await;
+ let mut agent = effort_agent(acp, Some("high"));
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let opts = captured_config_options(&obs);
+ assert_eq!(
+ opts,
+ serde_json::from_str::(opts_no_effort).unwrap(),
+ "no thought_level option → capture is the untouched session/new snapshot"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_no_startup_effort_leaves_capture_unpatched() {
+ // No held effort at all: the set_config_option is never sent and the
+ // default currentValue survives into the capture.
+ let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await;
+ let mut agent = effort_agent(acp, None);
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let opts = captured_config_options(&obs);
+ assert_eq!(
+ effort_current_value(&opts).as_deref(),
+ Some("low"),
+ "with no configured effort the capture reflects the model default"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_transport_error_on_effort_propagates_for_respawn() {
+ // Adapter exits after answering session/new but before the effort set →
+ // AgentExited (transport class) → Err so the caller respawns the worker
+ // instead of reusing a possibly-poisoned stream.
+ let script = format!(
+ r#"IFS= read -r _new
+printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{OPTS_WITH_EFFORT_DEFAULT_LOW}}}}}'
+IFS= read -r _effort
+exit 0"#
+ );
+ let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false)
+ .await
+ .expect("spawn transport-exit ACP script");
+ let mut agent = effort_agent(acp, Some("high"));
+
+ let ctx = make_prompt_context_no_owner();
+ let err = create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect_err("transport-class effort failure must propagate as Err");
+ assert!(
+ matches!(err, AcpError::AgentExited | AcpError::Io(_)),
+ "process exit mid-effort is a transport error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_patch_config_option_current_value_matches_by_id_key() {
+ // The `id` key (claude-agent-acp) must also match, not just `configId`.
+ let mut opts = serde_json::json!([
+ { "id": "effort", "category": "thought_level", "currentValue": "low" }
+ ]);
+ patch_config_option_current_value(&mut opts, "effort", "high");
+ assert_eq!(opts[0]["currentValue"], "high");
+ }
+
+ #[test]
+ fn test_patch_config_option_current_value_noop_on_non_array() {
+ let mut opts = serde_json::Value::Null;
+ patch_config_option_current_value(&mut opts, "effort", "high");
+ assert!(opts.is_null(), "a null snapshot must stay null");
+ }
+}
+
+#[cfg(test)]
+mod model_switch_tests {
+ use super::*;
+ use crate::acp::AcpClient;
+ use tests::make_prompt_context_no_owner;
+
+ /// A protocol-v2 agent with a live `desired_model` override and no startup
+ /// effort. `model_overridden` is set so the capture's `modelOverridden`
+ /// reflects only whether the switch actually landed.
+ fn switching_agent(acp: AcpClient, desired_model: &str) -> OwnedAgent {
+ OwnedAgent {
+ index: 0,
+ acp,
+ state: SessionState::default(),
+ model_capabilities: None,
+ desired_model: Some(desired_model.to_string()),
+ model_overridden: true,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: None,
+ agent_name: "switch-test-agent".into(),
+ goose_system_prompt_supported: None,
+ protocol_version: 2,
+ }
+ }
+
+ /// Scripted ACP: `session/new` (request #1) returns `session_new_options`,
+ /// then the model-switch `set_config_option` (request #2) replies with
+ /// `switch_reply` (a JSON-RPC `result`/`error` body minus the id). Any later
+ /// request gets `{"ok":true}`.
+ async fn spawn_switch_acp(session_new_options: &str, switch_reply: &str) -> AcpClient {
+ let script = format!(
+ r#"count=0
+while IFS= read -r line; do
+ count=$((count + 1))
+ id=$((count - 1))
+ if [ "$count" -eq 1 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_options}}}}}'
+ elif [ "$count" -eq 2 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}'
+ else
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}'
+ fi
+done"#
+ );
+ AcpClient::spawn("bash", &["-c".to_string(), script], &[], false)
+ .await
+ .expect("spawn switch ACP script")
+ }
+
+ fn capture(obs: &observer::ObserverHandle) -> serde_json::Value {
+ obs.snapshot()
+ .into_iter()
+ .find(|e| e.kind == "session_config_captured")
+ .expect("session_config_captured emitted")
+ .payload
+ }
+
+ fn control_results(obs: &observer::ObserverHandle) -> Vec {
+ obs.snapshot()
+ .into_iter()
+ .filter(|e| e.kind == "control_result")
+ .map(|e| e.payload)
+ .collect()
+ }
+
+ // A `model`-category option offering the default model plus the target the
+ // agent wants to switch to.
+ const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#;
+
+ #[tokio::test]
+ async fn test_applied_switch_refreshes_capabilities_from_post_switch_snapshot() {
+ // The adapter accepts the switch and echoes the target model's rebuilt
+ // configOptions — including a thought_level option the default model
+ // never advertised. Capabilities and the capture must reflect the target
+ // model, not the pre-switch default.
+ let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"medium","options":[{"value":"low"},{"value":"medium"}]}]}"#;
+ let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, switch_reply).await;
+ let mut agent = switching_agent(acp, "model-b");
+ // Busy path: this switch was delivered to an in-flight turn and its apply
+ // is deferred to this requeued session. Arm the pending-ack and carry the
+ // pick's correlator so the Applied arm emits a correlated positive
+ // terminal instead of leaving the Desktop to infer success from silence.
+ agent.desired_model_pending_ack = true;
+ agent.desired_model_request_id = Some("req-busy-1".into());
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let caps = agent
+ .model_capabilities
+ .as_ref()
+ .expect("capabilities refreshed from the post-switch snapshot");
+ assert_eq!(
+ caps.thought_level_config_id.as_deref(),
+ Some("effort"),
+ "the target model's thought_level option must be discovered post-switch"
+ );
+ let cap = capture(&obs);
+ assert_eq!(
+ cap["modelOverridden"], true,
+ "an applied switch must report modelOverridden true"
+ );
+ assert!(
+ cap["configOptions"]
+ .as_array()
+ .is_some_and(|a| a.iter().any(|o| o["category"] == "thought_level")),
+ "the cached configOptions must be the target model's post-switch set"
+ );
+ // The deferred apply must emit exactly one correlated positive terminal
+ // so the Desktop learns success from a real frame, not timeout silence.
+ let results = control_results(&obs);
+ assert_eq!(
+ results.len(),
+ 1,
+ "a busy-path applied switch emits exactly one positive terminal"
+ );
+ assert_eq!(results[0]["status"], "switched");
+ assert_eq!(results[0]["modelId"], "model-b");
+ assert_eq!(
+ results[0]["requestId"], "req-busy-1",
+ "the positive terminal must carry the pick's correlator"
+ );
+ assert!(
+ !agent.desired_model_pending_ack,
+ "the pending-ack is consumed once so it cannot re-fire on a later session"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_rejected_switch_preserves_capabilities_and_emits_failure() {
+ // The adapter refuses the switch with a JSON-RPC error. The session is
+ // still on its default model: pre-switch capabilities survive, the
+ // capture reports modelOverridden false, and a terminal `failure`
+ // control_result tells Desktop the pick did not land.
+ let acp = spawn_switch_acp(
+ OPTS_MODEL_A_AND_B,
+ r#""error":{"code":-32602,"message":"model not accepted"}"#,
+ )
+ .await;
+ let mut agent = switching_agent(acp, "model-b");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("an application-level rejection is non-fatal");
+
+ let caps = agent
+ .model_capabilities
+ .as_ref()
+ .expect("pre-switch capabilities must be preserved on rejection");
+ assert!(
+ caps.config_options_raw
+ .iter()
+ .any(|o| o["currentValue"] == "model-a"),
+ "capabilities must still describe the default model the session runs"
+ );
+ let cap = capture(&obs);
+ assert_eq!(
+ cap["modelOverridden"], false,
+ "a rejected switch must not claim an override"
+ );
+ let results = control_results(&obs);
+ assert_eq!(results.len(), 1, "exactly one control_result on rejection");
+ assert_eq!(results[0]["status"], "failure");
+ assert_eq!(results[0]["modelId"], "model-b");
+ }
+
+ #[tokio::test]
+ async fn test_busy_path_rejection_emits_only_failure_and_consumes_pending_ack() {
+ // K1 delayed-rejection at the Rust seam: a busy-path switch is armed
+ // (pending_ack), its apply is deferred to this requeued session, and the
+ // adapter then refuses it. The rejection arm must emit exactly one
+ // `failure` (no spurious positive `switched`) and consume the pending-ack
+ // so no later session can fire a phantom success.
+ let acp = spawn_switch_acp(
+ OPTS_MODEL_A_AND_B,
+ r#""error":{"code":-32602,"message":"model not accepted"}"#,
+ )
+ .await;
+ let mut agent = switching_agent(acp, "model-b");
+ agent.desired_model_pending_ack = true;
+ agent.desired_model_request_id = Some("req-busy-reject".into());
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("an application-level rejection is non-fatal");
+
+ let results = control_results(&obs);
+ assert_eq!(
+ results.len(),
+ 1,
+ "a busy-path rejection emits exactly one terminal — no phantom success"
+ );
+ assert_eq!(results[0]["status"], "failure");
+ assert_eq!(results[0]["requestId"], "req-busy-reject");
+ assert!(
+ !agent.desired_model_pending_ack,
+ "the pending-ack is consumed even on rejection so it cannot re-fire"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_applied_switch_without_options_drops_capabilities() {
+ // A successful switch whose response carries no configOptions (older
+ // adapter, or a model with no options): the pre-switch snapshot cannot
+ // be trusted for the target model, so capabilities drop to None to be
+ // re-derived on the next session — but the switch still counts as an
+ // override with no failure surfaced.
+ let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await;
+ let mut agent = switching_agent(acp, "model-b");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ assert!(
+ agent.model_capabilities.is_none(),
+ "an optionless successful switch must drop stale capabilities"
+ );
+ let cap = capture(&obs);
+ assert_eq!(
+ cap["modelOverridden"], true,
+ "the switch still applied even with no echoed options"
+ );
+ assert!(
+ control_results(&obs).is_empty(),
+ "a successful switch emits no failure control_result"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_unsupported_model_emits_unsupported_without_switch_rpc() {
+ // The desired model is absent from the session/new catalog: no switch
+ // RPC is sent, the capture reports no override, and an
+ // `unsupported_model` control_result rejects the live pick.
+ let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await;
+ let mut agent = switching_agent(acp, "model-z");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("an unresolvable model is non-fatal");
+
+ let cap = capture(&obs);
+ assert_eq!(cap["modelOverridden"], false);
+ let results = control_results(&obs);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0]["status"], "unsupported_model");
+ assert_eq!(results[0]["modelId"], "model-z");
+ }
+
+ /// Scripted ACP whose `session/new` (request #1) returns a full result body
+ /// `session_new_result` (a JSON object minus the outer envelope), and whose
+ /// model-switch `set_config_option` (request #2) replies with `switch_reply`
+ /// (a JSON-RPC `result`/`error` body minus the id). Lets a test control the
+ /// `models` block in both the pre-switch and post-switch snapshots.
+ async fn spawn_switch_acp_full(session_new_result: &str, switch_reply: &str) -> AcpClient {
+ let script = format!(
+ r#"count=0
+while IFS= read -r line; do
+ count=$((count + 1))
+ id=$((count - 1))
+ if [ "$count" -eq 1 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{session_new_result}}}'
+ elif [ "$count" -eq 2 ]; then
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}'
+ else
+ printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}'
+ fi
+done"#
+ );
+ AcpClient::spawn("bash", &["-c".to_string(), script], &[], false)
+ .await
+ .expect("spawn switch ACP script")
+ }
+
+ /// F3: an applied switch must cache `models` from the POST-switch snapshot,
+ /// not the pre-switch `session/new` response. The pre-switch snapshot reports
+ /// the default model as current; the target response reports the target as
+ /// current. The emitted capture must carry the target's models block. The
+ /// Desktop-parsing half of this contract lives in `agent_config_tests.rs`
+ /// (`live_switch_models_from_post_switch_snapshot_parses_target_current`).
+ #[tokio::test]
+ async fn test_applied_switch_caches_target_model_not_pre_switch() {
+ // session/new: model-a is current. switch reply: model-b is current,
+ // and it echoes rebuilt configOptions so capabilities refresh cleanly.
+ let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#;
+ let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-b","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#;
+ let acp = spawn_switch_acp_full(session_new, switch_reply).await;
+ let mut agent = switching_agent(acp, "model-b");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let cap = capture(&obs);
+ assert_eq!(
+ cap["models"]["currentModelId"], "model-b",
+ "an applied switch must cache the target model, not the pre-switch model-a"
+ );
+ }
+
+ /// F3: an applied switch whose target response omits `models` must cache
+ /// Null — never fall back to the pre-switch `resp.raw.models`. Otherwise the
+ /// panel would report the pre-switch model as live after a successful switch.
+ #[tokio::test]
+ async fn test_applied_switch_without_models_does_not_leak_pre_switch_model() {
+ // session/new advertises model-a as current; the successful switch reply
+ // echoes configOptions (so the switch is Applied) but NO models block.
+ let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#;
+ let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}]}"#;
+ let acp = spawn_switch_acp_full(session_new, switch_reply).await;
+ let mut agent = switching_agent(acp, "model-b");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let cap = capture(&obs);
+ assert!(
+ cap["models"].is_null(),
+ "an optionless-models successful switch must emit Null, not the pre-switch models"
+ );
+ }
+
+ /// Like `switching_agent` but also holds a spawn-scoped startup effort, so a
+ /// single session creation both switches the model AND applies startup
+ /// effort — the interaction F5.6 pins.
+ fn switching_agent_with_effort(
+ acp: AcpClient,
+ desired_model: &str,
+ startup_effort: &str,
+ ) -> OwnedAgent {
+ OwnedAgent {
+ index: 0,
+ acp,
+ state: SessionState::default(),
+ model_capabilities: None,
+ desired_model: Some(desired_model.to_string()),
+ model_overridden: true,
+ desired_model_request_id: None,
+ desired_model_pending_ack: false,
+ startup_effort: Some(startup_effort.to_string()),
+ agent_name: "switch-effort-test-agent".into(),
+ goose_system_prompt_supported: None,
+ protocol_version: 2,
+ }
+ }
+
+ fn effort_option_current_value(cap: &serde_json::Value) -> Option {
+ cap["configOptions"]
+ .as_array()?
+ .iter()
+ .find(|o| o["category"] == "thought_level")
+ .and_then(|o| o["currentValue"].as_str())
+ .map(str::to_string)
+ }
+
+ /// F5.6: startup effort resolves against the TARGET model's option set. The
+ /// pre-switch model-a advertises no `thought_level`; only the post-switch
+ /// model-b does. `apply_startup_effort` reads the post-switch snapshot, so
+ /// the held `high` applies against model-b's option and the cached
+ /// configOptions show it at `high`. Had it read the pre-switch snapshot the
+ /// effort would find no option and silently no-op.
+ #[tokio::test]
+ async fn test_startup_effort_resolves_against_post_switch_target_options() {
+ // session/new: model-a, model option only — NO thought_level.
+ let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#;
+ // switch reply: model-b current AND a target-only thought_level option.
+ let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]}"#;
+ let acp = spawn_switch_acp(session_new, switch_reply).await;
+ let mut agent = switching_agent_with_effort(acp, "model-b", "high");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let cap = capture(&obs);
+ assert_eq!(
+ effort_option_current_value(&cap).as_deref(),
+ Some("high"),
+ "startup effort must apply against the target model's thought_level option"
+ );
+ }
+
+ /// F5.6: an applied switch whose target response echoes NO options must not
+ /// apply the held startup effort against the STALE pre-switch options. The
+ /// pre-switch model-a advertised a `thought_level` option; the optionless
+ /// target response means the effort has no target option and must be
+ /// skipped — so the cached configOptions are Null, never the pre-switch
+ /// model-a options with a falsely patched `high`.
+ #[tokio::test]
+ async fn test_startup_effort_skips_stale_options_on_optionless_switch() {
+ // session/new: model-a WITH a thought_level option.
+ let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#;
+ // switch reply: applied, but NO echoed options.
+ let switch_reply = r#""result":{"ok":true}"#;
+ let acp = spawn_switch_acp(session_new, switch_reply).await;
+ let mut agent = switching_agent_with_effort(acp, "model-b", "high");
+ let obs = observer::ObserverHandle::in_process();
+ agent.acp.set_observer(Some(obs.clone()), 0);
+
+ let ctx = make_prompt_context_no_owner();
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ None,
+ NewSessionChannelContext {
+ huddle_instructions: None,
+ canvas: None,
+ name: None,
+ id: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .expect("session creation must succeed");
+
+ let cap = capture(&obs);
+ assert_eq!(
+ cap["modelOverridden"], true,
+ "the switch still applied even with no echoed options"
+ );
+ assert!(
+ cap["configOptions"].is_null(),
+ "an optionless switch caches the target's (empty) options, never the pre-switch model-a options with a patched effort"
+ );
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs
index badff621e8..5fc27ab905 100644
--- a/crates/buzz-backend-kubernetes/src/env.rs
+++ b/crates/buzz-backend-kubernetes/src/env.rs
@@ -389,6 +389,91 @@ mod tests {
assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
}
+ /// F2 provider seam: the desktop strips both model keys from a Claude
+ /// launch.env and rides the canonical model on policy_env alone. This test
+ /// pins the final `build_env` output for that shape: the canonical
+ /// ANTHROPIC_MODEL survives (tier 1, no tier-2 key to overwrite it) and
+ /// BUZZ_ACP_MODEL is absent — so the remote process has exactly one model
+ /// authority. Same-value and conflicting-value collisions are both moot
+ /// because the desktop already removed the launch.env keys.
+ #[test]
+ fn claude_launch_yields_single_model_authority_through_build_env() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "command": "claude",
+ "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
+ // Desktop stripped both model keys from launch.env for claude.
+ "env": {"KEEP_ME": "yes"},
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(
+ env["ANTHROPIC_MODEL"], "claude-opus-4",
+ "canonical model must survive as the single authority"
+ );
+ assert!(
+ !env.contains_key("BUZZ_ACP_MODEL"),
+ "no second model authority may reach the remote process"
+ );
+ assert_eq!(env["KEEP_ME"], "yes");
+ }
+
+ /// F2 provider seam, adversarial: even if a launch.env somehow still carries
+ /// model keys (older desktop, tampering), tier 2 later-wins over tier 1 —
+ /// which is exactly why the desktop must strip them. This documents the
+ /// hazard the desktop fix prevents: a launch.env ANTHROPIC_MODEL overrides
+ /// the canonical, and a launch.env BUZZ_ACP_MODEL introduces a second
+ /// authority. Neither key is authoritative in k8s, so the provider cannot
+ /// defend against it — the desktop strip is the only guard.
+ #[test]
+ fn launch_env_model_keys_would_win_over_policy_env_documenting_the_hazard() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "command": "claude",
+ "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
+ "env": {"ANTHROPIC_MODEL": "user-haiku", "BUZZ_ACP_MODEL": "user-sonnet"},
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(
+ env["ANTHROPIC_MODEL"], "user-haiku",
+ "launch.env later-wins — proving the desktop must strip it"
+ );
+ assert_eq!(
+ env["BUZZ_ACP_MODEL"], "user-sonnet",
+ "a leftover BUZZ_ACP_MODEL would be a second authority — desktop strips it"
+ );
+ }
+
+ /// F2 provider seam, same-value collision: a leftover launch.env
+ /// ANTHROPIC_MODEL that happens to match the canonical policy_env value is
+ /// still a second authority structurally — tier 2 later-wins, so the value
+ /// the remote process sees comes from launch.env, not the canonical tier.
+ /// It is only benign because the strings coincide; the desktop strip is what
+ /// guarantees the canonical tier is authoritative regardless of the leftover
+ /// value. Pinning the same-value case proves `build_env` cannot itself
+ /// distinguish a matching leftover from a conflicting one.
+ #[test]
+ fn launch_env_same_value_model_key_still_rides_tier_two_through_build_env() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "command": "claude",
+ "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
+ // Same value as the canonical policy_env entry.
+ "env": {"ANTHROPIC_MODEL": "claude-opus-4"},
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(
+ env["ANTHROPIC_MODEL"], "claude-opus-4",
+ "value coincides, but it is tier 2 (launch.env) that wins — the \
+ provider cannot tell a matching leftover from a conflicting one"
+ );
+ }
+
/// `launch.env` already contains the merged user env, so re-merging the
/// legacy field would undo a layering the desktop already resolved.
#[test]
diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs
index 9da920483f..40c5464783 100644
--- a/crates/buzz-relay/src/handlers/identity_archive.rs
+++ b/crates/buzz-relay/src/handlers/identity_archive.rs
@@ -512,6 +512,196 @@ mod tests {
TenantContext::resolved(CommunityId::from_uuid(id), host)
}
+ #[tokio::test]
+ async fn archival_snapshot_advances_timestamp_for_rapid_state_replacement() {
+ let Some(pool) = test_pool().await else {
+ return;
+ };
+ if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1")
+ .execute(&pool)
+ .await
+ .is_err()
+ {
+ return;
+ }
+ let Some(state) = test_state(pool.clone()).await else {
+ return;
+ };
+ let tenant = seed_test_community(&pool).await;
+ let target_hex = Keys::generate().public_key().to_hex();
+ let request_id = "a".repeat(64);
+
+ state
+ .db
+ .archive(
+ tenant.community(),
+ &target_hex,
+ "self",
+ &target_hex,
+ None,
+ None,
+ &request_id,
+ )
+ .await
+ .expect("archive identity");
+ publish_nipia_archival_list(&tenant, &state)
+ .await
+ .expect("publish archived snapshot");
+ let archived_snapshot = state
+ .db
+ .query_events(&EventQuery {
+ kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
+ pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
+ global_only: true,
+ limit: Some(1),
+ ..EventQuery::for_community(tenant.community())
+ })
+ .await
+ .expect("query archived snapshot")
+ .into_iter()
+ .next()
+ .expect("archived snapshot exists");
+
+ state
+ .db
+ .unarchive(tenant.community(), &target_hex)
+ .await
+ .expect("unarchive identity");
+ publish_nipia_archival_list(&tenant, &state)
+ .await
+ .expect("publish unarchived snapshot");
+ let final_snapshot = state
+ .db
+ .query_events(&EventQuery {
+ kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
+ pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
+ global_only: true,
+ limit: Some(1),
+ ..EventQuery::for_community(tenant.community())
+ })
+ .await
+ .expect("query final snapshot")
+ .into_iter()
+ .next()
+ .expect("final snapshot exists");
+
+ assert!(
+ final_snapshot.event.created_at > archived_snapshot.event.created_at,
+ "replacement snapshots must not rely on random same-second event-id ordering"
+ );
+ assert!(
+ !final_snapshot.event.tags.iter().any(|tag| {
+ let fields = tag.as_slice();
+ fields.first().map(String::as_str) == Some("p")
+ && fields.get(1).map(String::as_str) == Some(target_hex.as_str())
+ }),
+ "final snapshot must reflect the canonical empty archive set"
+ );
+ }
+
+ /// Carl review 4954871389 test (b): a stale (pre-unarchive) publisher whose
+ /// canonical read predates the unarchive must not strand `target` in the
+ /// authoritative 13535. Deterministic via the `publish_test_hooks` barrier:
+ /// the stale publisher is held right after it reads `{target}`; the
+ /// unarchive and the compliant `{}` publish then run; only then is the stale
+ /// publisher released to attempt its write. Its post-insert
+ /// `snapshot_is_current` drift check sees canonical `{}` ≠ its `{target}`
+ /// snapshot, so it rebuilds and converges. RED-on-revert: replace that guard
+ /// with `let snapshot_is_current = true;` and the released stale publisher
+ /// commits `{target}` last, stranding the unarchived identity.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn concurrent_archival_publishers_converge_on_canonical_state() {
+ let Some(pool) = test_pool().await else {
+ return;
+ };
+ if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1")
+ .execute(&pool)
+ .await
+ .is_err()
+ {
+ return;
+ }
+ let Some(state) = test_state(pool.clone()).await else {
+ return;
+ };
+ let tenant = seed_test_community(&pool).await;
+ let target_hex = Keys::generate().public_key().to_hex();
+ let request_id = "b".repeat(64);
+
+ // canonical -> {target}
+ state
+ .db
+ .archive(
+ tenant.community(),
+ &target_hex,
+ "self",
+ &target_hex,
+ None,
+ None,
+ &request_id,
+ )
+ .await
+ .expect("archive identity");
+
+ // Arm the barrier, then spawn the stale publisher. It reads the
+ // `{target}` view, reaches the hook, and blocks until released.
+ let (reached_hook, release) =
+ crate::handlers::side_effects::publish_test_hooks::arm(tenant.community());
+ let stale_tenant = tenant.clone();
+ let stale_state = state.clone();
+ let stale_publisher =
+ tokio::spawn(
+ async move { publish_nipia_archival_list(&stale_tenant, &stale_state).await },
+ );
+ // Deterministically wait until the stale publisher has read `{target}`.
+ reached_hook
+ .await
+ .expect("stale publisher reached the post-list_archived hook");
+
+ // canonical -> {} while the stale publisher holds its `{target}` view.
+ state
+ .db
+ .unarchive(tenant.community(), &target_hex)
+ .await
+ .expect("unarchive identity");
+ // Production publishes after every archive-state mutation; do the same.
+ publish_nipia_archival_list(&tenant, &state)
+ .await
+ .expect("publish after unarchive");
+
+ // Release the stale publisher: it must detect drift and converge on `{}`.
+ release.notify_one();
+ stale_publisher
+ .await
+ .expect("join stale publisher")
+ .expect("stale publisher converges without error");
+
+ let final_snapshot = state
+ .db
+ .query_events(&EventQuery {
+ kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
+ pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
+ global_only: true,
+ limit: Some(1),
+ ..EventQuery::for_community(tenant.community())
+ })
+ .await
+ .expect("query final snapshot")
+ .into_iter()
+ .next()
+ .expect("final snapshot exists");
+
+ assert!(
+ !final_snapshot.event.tags.iter().any(|tag| {
+ let fields = tag.as_slice();
+ fields.first().map(String::as_str) == Some("p")
+ && fields.get(1).map(String::as_str) == Some(target_hex.as_str())
+ }),
+ "a stale publisher must converge on the canonical empty set, never \
+ strand the unarchived identity in the authoritative 13535"
+ );
+ }
+
#[tokio::test]
async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() {
let Some(pool) = test_pool().await else {
diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs
index 282ea77657..f2b58937ab 100644
--- a/crates/buzz-relay/src/handlers/side_effects.rs
+++ b/crates/buzz-relay/src/handlers/side_effects.rs
@@ -3116,6 +3116,61 @@ pub async fn reconcile_channel_events(
Ok(())
}
+/// Test-only barrier hooks for [`publish_nipia_archival_list`]. Lets a test
+/// hold one publisher after it has read canonical archive state and before it
+/// replaces the head, making the stale-read/late-write race deterministic.
+/// Compiled only under `cfg(test)`; the production call site is `#[cfg(test)]`.
+///
+/// The gate is scoped to a `CommunityId`: only a publisher whose tenant matches
+/// the armed community is held. Publishers from other tenants — the rapid
+/// archive/unarchive or owner-archive regressions running in parallel under the
+/// Rust test runner — pass straight through and never consume the gate armed
+/// for the concurrent-publisher test's unique tenant.
+#[cfg(test)]
+pub(crate) mod publish_test_hooks {
+ use buzz_core::tenant::CommunityId;
+ use std::sync::{Arc, Mutex};
+ use tokio::sync::{oneshot, Notify};
+
+ struct Gate {
+ community: CommunityId,
+ arrived: oneshot::Sender<()>,
+ release: Arc,
+ }
+
+ static GATE: Mutex> = Mutex::new(None);
+
+ /// Arm a one-shot barrier for `community`. Await the returned receiver to
+ /// learn when the held publisher has reached the hook (i.e. has read
+ /// canonical state); call `notify_one` on the returned handle to let it
+ /// proceed. Only the first publisher of the matching community to reach the
+ /// hook after arming is held; every other publisher passes.
+ pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc) {
+ let (tx, rx) = oneshot::channel();
+ let release = Arc::new(Notify::new());
+ *GATE.lock().unwrap() = Some(Gate {
+ community,
+ arrived: tx,
+ release: release.clone(),
+ });
+ (rx, release)
+ }
+
+ pub(super) async fn after_list_archived(community: CommunityId) {
+ let gate = {
+ let mut slot = GATE.lock().unwrap();
+ match slot.as_ref() {
+ Some(gate) if gate.community == community => slot.take(),
+ _ => None,
+ }
+ };
+ if let Some(gate) = gate {
+ let _ = gate.arrived.send(());
+ gate.release.notified().await;
+ }
+ }
+}
+
/// Publish a kind:13535 archived identities list event (NIP-IA).
///
/// Queries all current archived identities and emits a relay-signed,
@@ -3124,29 +3179,76 @@ pub async fn publish_nipia_archival_list(
tenant: &TenantContext,
state: &Arc,
) -> anyhow::Result<()> {
- let archived = state.db.list_archived(tenant.community()).await?;
- let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
+ const MAX_REPLACEMENT_ATTEMPTS: usize = 8;
+ let relay_pubkey = state.relay_keypair.public_key();
+ let relay_pubkey_hex = relay_pubkey.to_hex();
- let mut tags: Vec = Vec::with_capacity(archived.len() + 1);
- tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
+ // A concurrent archive mutation can race between reading the current head and
+ // replacing it. Rebuild from canonical state on rejection so an older snapshot
+ // can never strand the final archive set.
+ for _ in 0..MAX_REPLACEMENT_ATTEMPTS {
+ let archived = state.db.list_archived(tenant.community()).await?;
+ // Test-only barrier: lets a test hold a stale publisher here — after it
+ // has read canonical state, before it replaces the head — so the
+ // stale-read/late-write ordering the drift check must catch is
+ // deterministic, not scheduler-dependent. Inert in production.
+ #[cfg(test)]
+ publish_test_hooks::after_list_archived(tenant.community()).await;
+ let mut tags: Vec = Vec::with_capacity(archived.len() + 1);
+ tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
- for identity in &archived {
- tags.push(
- Tag::parse(["p", &identity.pubkey])
- .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
- );
- }
+ for identity in &archived {
+ tags.push(
+ Tag::parse(["p", &identity.pubkey])
+ .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
+ );
+ }
- let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
- .tags(tags)
- .sign_with_keys(&state.relay_keypair)
- .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?;
+ // NIP-16 resolves same-second replacements by event id. Force this
+ // canonical snapshot strictly past the current head instead of letting a
+ // rapid archive→unarchive randomly preserve the stale archive state.
+ let now = nostr::Timestamp::now().as_secs();
+ let previous = state
+ .db
+ .query_events(&buzz_db::event::EventQuery {
+ kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]),
+ pubkey: Some(relay_pubkey.to_bytes().to_vec()),
+ limit: Some(1),
+ global_only: true,
+ ..buzz_db::event::EventQuery::for_community(tenant.community())
+ })
+ .await?;
+ let created_at = previous
+ .first()
+ .map(|event| (event.event.created_at.as_secs() + 1).max(now))
+ .unwrap_or(now);
+
+ let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
+ .tags(tags)
+ .custom_created_at(nostr::Timestamp::from(created_at))
+ .sign_with_keys(&state.relay_keypair)
+ .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?;
+
+ let (stored, was_inserted) = state
+ .db
+ .replace_addressable_event(tenant.community(), &event, None)
+ .await?;
+ if !was_inserted {
+ continue;
+ }
+
+ let current_archived = state.db.list_archived(tenant.community()).await?;
+ let snapshot_is_current =
+ archived
+ .iter()
+ .map(|identity| identity.pubkey.as_str())
+ .eq(current_archived
+ .iter()
+ .map(|identity| identity.pubkey.as_str()));
+ if !snapshot_is_current {
+ continue;
+ }
- let (stored, was_inserted) = state
- .db
- .replace_addressable_event(tenant.community(), &event, None)
- .await?;
- if was_inserted {
dispatch_persistent_event(
tenant,
state,
@@ -3156,13 +3258,16 @@ pub async fn publish_nipia_archival_list(
None,
)
.await;
+ info!(
+ archived_count = archived.len(),
+ "NIP-IA archived identities list published"
+ );
+ return Ok(());
}
- info!(
- archived_count = archived.len(),
- "NIP-IA archived identities list published"
- );
- Ok(())
+ anyhow::bail!(
+ "failed to publish kind:{KIND_IA_ARCHIVED_LIST} after {MAX_REPLACEMENT_ATTEMPTS} concurrent replacements"
+ )
}
/// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for
diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs
index 2dc0ba0d69..4df24e6e9b 100644
--- a/desktop/src-tauri/src/commands/agent_config.rs
+++ b/desktop/src-tauri/src/commands/agent_config.rs
@@ -13,9 +13,10 @@ use crate::{
},
},
current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key,
- known_acp_runtime, load_managed_agents, load_personas, save_managed_agents,
- sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime,
- ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES,
+ known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env,
+ save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind,
+ GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey,
+ MAX_ENV_VALUE_BYTES,
},
};
@@ -121,6 +122,7 @@ fn resolve_config_surface(
runtime_meta: Option<&KnownAcpRuntime>,
session_cache: Option<&SessionConfigCache>,
global: &GlobalAgentConfig,
+ claude_config_dir: Option<&std::path::Path>,
) -> RuntimeConfigSurface {
// Linked instances are definition-authoritative: clear stale materialized
// model/provider/prompt so they can never masquerade as BuzzExplicit and
@@ -138,7 +140,13 @@ fn resolve_config_surface(
global,
);
- read_config_surface(&record, runtime_meta, session_cache, &tiers)
+ read_config_surface(
+ &record,
+ runtime_meta,
+ session_cache,
+ &tiers,
+ claude_config_dir,
+ )
}
/// Get the file-layer config for a runtime — used by the Create/Edit/Persona
@@ -288,12 +296,36 @@ pub async fn get_agent_config_surface(
let session_cache = state.get_session_cache(&runtime_key);
let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
+ // #3493: for claude agents, resolve the settings.json and .claude.json paths
+ // from the agent's effective CLAUDE_CONFIG_DIR env var (if set), falling
+ // back to ~/.claude/ and ~/.claude.json. We never provision this dir
+ // ourselves — we only respect what the user configured.
+ //
+ // Use resolve_effective_agent_env so the lookup covers all tiers (baked
+ // floor → definition → global → persona → record) and cannot diverge from
+ // what the spawned process actually sees.
+ let claude_config_dir: Option = if runtime_meta
+ .is_some_and(|m| m.id == "claude")
+ {
+ let effective_env = resolve_effective_agent_env(&record, &personas, runtime_meta, &global);
+ // Treat empty or blank CLAUDE_CONFIG_DIR as unset, matching Claude's
+ // `CLAUDE_CONFIG_DIR || homedir()` resolver semantics.
+ effective_env
+ .env
+ .get("CLAUDE_CONFIG_DIR")
+ .filter(|v| !v.trim().is_empty())
+ .map(std::path::PathBuf::from)
+ } else {
+ None
+ };
+
Ok(resolve_config_surface(
record,
&personas,
runtime_meta,
session_cache.as_ref(),
&global,
+ claude_config_dir.as_deref(),
))
}
@@ -503,6 +535,44 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option<
(models, current_model)
}
+/// Persist the canonical startup effort level for a local managed agent.
+///
+/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the
+/// effort a spawn will apply at next session start. The value is stored on the
+/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the
+/// harness applies it via `session/set_config_option` against the adapter's
+/// advertised `thought_level` configId. Pass `None` to clear (adapter default).
+///
+/// Rejects non-local backends: remote agents receive effort through `policy_env`
+/// at deploy time (see `agents_deploy.rs`), never this local persistence path —
+/// so an effort edit against a deployed agent is a caller error, not a silent
+/// no-op that leaves the panel and the running agent disagreeing.
+#[tauri::command]
+pub fn persist_agent_effort_level(
+ pubkey: String,
+ effort_level: Option,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result<(), String> {
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let mut records = load_managed_agents(&app)?;
+ let record = records
+ .iter_mut()
+ .find(|r| r.pubkey == pubkey)
+ .ok_or_else(|| format!("agent {pubkey} not found"))?;
+ if record.backend != BackendKind::Local {
+ return Err(format!(
+ "agent {pubkey} is not a local agent; remote effort is set at deploy time"
+ ));
+ }
+ record.effort_level = effort_level;
+ record.updated_at = crate::util::now_iso();
+ save_managed_agents(&app, &records)
+}
+
#[cfg(test)]
#[path = "agent_config_tests.rs"]
mod tests;
diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs
index 77e43f5d64..9c9aa58c1f 100644
--- a/desktop/src-tauri/src/commands/agent_config_tests.rs
+++ b/desktop/src-tauri/src/commands/agent_config_tests.rs
@@ -117,6 +117,7 @@ fn agent_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
@@ -182,6 +183,7 @@ fn linked_stale_record_model_never_outranks_persona_model() {
Some(goose_runtime()),
None,
&Default::default(),
+ None,
);
let model = surface.normalized.model.as_ref().expect("model resolved");
@@ -206,7 +208,14 @@ fn linked_blank_definition_model_falls_through_to_global_default() {
..Default::default()
};
- let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global);
+ let surface = resolve_config_surface(
+ record,
+ &personas,
+ Some(goose_runtime()),
+ None,
+ &global,
+ None,
+ );
let model = surface.normalized.model.as_ref().expect("model resolved");
assert_eq!(model.value.as_deref(), Some("global-model"));
@@ -229,6 +238,7 @@ fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() {
Some(goose_runtime()),
None,
&Default::default(),
+ None,
);
let model = surface.normalized.model.as_ref().expect("model resolved");
@@ -256,6 +266,7 @@ fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() {
Some(goose_runtime()),
Some(&cache),
&Default::default(),
+ None,
);
let model = surface.normalized.model.expect("model resolved");
@@ -284,6 +295,7 @@ fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() {
Some(goose_runtime()),
Some(&cache),
&Default::default(),
+ None,
);
let model = surface.normalized.model.expect("model resolved");
@@ -319,6 +331,7 @@ fn genuine_explicit_live_switch_to_same_model_yields_clean_field() {
Some(goose_runtime()),
Some(&cache),
&Default::default(),
+ None,
)
});
let model = surface.normalized.model.expect("model resolved");
@@ -347,6 +360,7 @@ fn persona_linked_live_switch_keeps_persona_default_secondary() {
Some(goose_runtime()),
Some(&cache),
&Default::default(),
+ None,
);
let model = surface.normalized.model.expect("model resolved");
@@ -382,6 +396,7 @@ fn global_default_live_switch_renders_global_model_as_secondary_global_default()
Some(goose_runtime()),
Some(&cache),
&global,
+ None,
);
let model = surface.normalized.model.expect("model resolved");
@@ -666,3 +681,32 @@ fn baked_env_allowlist_is_case_insensitive() {
// Unknown key → masked by default.
assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY"));
}
+
+/// F3 (Desktop-parsing half): the `models` block emitted by an applied live
+/// switch — taken from the post-switch snapshot in `pool.rs` — must parse to the
+/// target model as current. Pairs with the pool test
+/// `test_applied_switch_caches_target_model_not_pre_switch`, which proves the
+/// emitted block already carries `currentModelId=model-b`.
+#[test]
+fn live_switch_models_from_post_switch_snapshot_parses_target_current() {
+ let models = serde_json::json!({
+ "currentModelId": "model-b",
+ "availableModels": [{"modelId": "model-a"}, {"modelId": "model-b"}],
+ });
+ let (available, current) = parse_models(Some(&models));
+ assert_eq!(current.as_deref(), Some("model-b"));
+ assert_eq!(available.len(), 2);
+}
+
+/// F3 (Desktop-parsing half): a Null `models` block — emitted when a successful
+/// switch's target response omits `models` — must parse to no current model, so
+/// the pre-switch model is never revived in the cache.
+#[test]
+fn live_switch_null_models_parses_to_no_current_model() {
+ let (available, current) = parse_models(Some(&serde_json::Value::Null));
+ assert!(
+ current.is_none(),
+ "Null models must not surface any current model"
+ );
+ assert!(available.is_empty());
+}
diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
index d00969bf19..976519a076 100644
--- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
@@ -9,6 +9,41 @@ use crate::{
const RELAY_DIRECTORY_PAGE_SIZE: usize = 500;
const RELAY_FILTER_BATCH_SIZE: usize = 10;
+/// Per-rebuild ceiling on directory-rebuild `/query` requests in flight at once.
+/// The rebuild fans dozens of exact-author batches across the relay; issuing
+/// them serially dominated agent-mention send latency (~6 s for ~100
+/// candidates). A bounded window collapses that to a few round trips while
+/// keeping the request rate well under the relay's admission gate, which
+/// back-pressures any 429 anyway. Each rebuild builds one semaphore and shares
+/// it across every phase, so a single rebuild's runtime-directory and
+/// owner-profile phases — which run concurrently under one `try_join!` — never
+/// exceed it together. (Overlapping rebuilds each hold their own budget.)
+const RELAY_DIRECTORY_MAX_CONCURRENCY: usize = 8;
+
+/// Run one `query_relay` request per `RELAY_FILTER_BATCH_SIZE` chunk of
+/// `filters`, each acquiring a permit from `semaphore` so the total in-flight
+/// request count stays within the shared ceiling even when several batch sets
+/// run concurrently. Returned events are concatenated; order is unspecified —
+/// every caller keys the events by pubkey downstream, so ordering is irrelevant.
+async fn query_filter_batches(
+ state: &AppState,
+ semaphore: &tokio::sync::Semaphore,
+ filters: &[serde_json::Value],
+ error_label: &str,
+) -> Result, String> {
+ let pages = futures_util::future::try_join_all(filters.chunks(RELAY_FILTER_BATCH_SIZE).map(
+ |batch| async move {
+ let _permit = semaphore.acquire().await.map_err(|error| {
+ format!("{error_label}: directory concurrency semaphore closed: {error}")
+ })?;
+ query_relay(state, batch)
+ .await
+ .map_err(|error| format!("{error_label}: {error}"))
+ },
+ ))
+ .await?;
+ Ok(pages.into_iter().flatten().collect())
+}
fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec {
pubkeys
@@ -129,24 +164,26 @@ async fn list_relay_agents_for_selection(
return Ok(Vec::new());
}
- let mut directory_events = Vec::new();
- let mut profile_events = Vec::new();
let directory_filters = exact_author_filters(&candidate_pubkeys, 10100);
let profile_filters = exact_author_filters(&candidate_pubkeys, 0);
- for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) {
- let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len());
- let (directory, profiles) = tokio::join!(
- query_relay(state, &directory_filters[filter_offset..filter_end]),
- query_relay(state, &profile_filters[filter_offset..filter_end]),
- );
- directory_events.extend(
- directory
- .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?,
- );
- profile_events.extend(
- profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?,
- );
- }
+ // One semaphore per rebuild caps `/query` requests across this rebuild's
+ // phases, so its runtime-directory and owner-profile phases below stay
+ // within the ceiling even though `try_join!` runs them concurrently.
+ let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY);
+ let (directory_events, profile_events) = tokio::try_join!(
+ query_filter_batches(
+ state,
+ &semaphore,
+ &directory_filters,
+ "relay agent runtime-directory query failed",
+ ),
+ query_filter_batches(
+ state,
+ &semaphore,
+ &profile_filters,
+ "relay agent owner-profile query failed",
+ ),
+ )?;
// Only the agent's signed NIP-OA profile can name the owner coordinate to
// query. Each exact `(owner, d=agent)` filter returns at most one current
@@ -161,14 +198,13 @@ async fn list_relay_agents_for_selection(
retain_verified_owner(&mut verified_owners, &viewer_pubkey);
}
let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners);
- let mut managed_agent_events = Vec::new();
- for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) {
- managed_agent_events.extend(
- query_relay(state, filters)
- .await
- .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?,
- );
- }
+ let managed_agent_events = query_filter_batches(
+ state,
+ &semaphore,
+ &managed_filters,
+ "relay agent managed-policy query failed",
+ )
+ .await?;
let mut agents = nostr_convert::relay_agents_from_directory_events(
&directory_events,
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index d1667a72bb..ed7a33d397 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -864,6 +864,7 @@ pub async fn create_managed_agent(
} else {
relay_mesh.clone()
},
+ effort_level: None,
};
records.push(record);
@@ -1238,12 +1239,8 @@ pub async fn delete_managed_agent(
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
- // Remove the agent's nsec from the keyring after the record is gone.
crate::managed_agents::delete_agent_key(&pubkey);
- // Tombstone-after-validation: only reached past the deployed-remote
- // guard above and a confirmed removal — never orphan a live remote
- // deployment's relay record. Inside the lock, before the block closes
- // (no .await here). Every agent published, so every delete tombstones.
+ // Tombstone after confirmed removal (inside lock; every published agent tombstones).
tombstone_managed_agent_pending(&app, &state, &pubkey);
// NIP-IA: archive the deleted agent's identity on the relay so it
// stops appearing in member pickers and autocomplete. Same
diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs
index 483eb60134..da5bb3ba5c 100644
--- a/desktop/src-tauri/src/commands/agents_deploy.rs
+++ b/desktop/src-tauri/src/commands/agents_deploy.rs
@@ -83,7 +83,25 @@ pub(super) fn build_launch_block(
policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string());
}
if let Some(value) = effective_model {
- policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string());
+ // B2: remote env-authority model key. Claude's startup model authority
+ // is ANTHROPIC_MODEL (same as the local A1 path — the harness reads it
+ // first and skips the BUZZ_ACP_MODEL catalog-switch path that would
+ // introduce a second startup authority). All other runtimes use
+ // BUZZ_ACP_MODEL, which the harness reads into desired_model at spawn.
+ let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false);
+ let model_key = if is_claude {
+ "ANTHROPIC_MODEL"
+ } else {
+ "BUZZ_ACP_MODEL"
+ };
+ policy_env.insert(model_key.into(), value.to_string());
+ }
+ // I-4: remote parity for persisted startup effort. Mirrors the local spawn
+ // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into
+ // PoolStartup.startup_effort and applies it at first session creation via
+ // resolve_startup_effort().
+ if let Some(ref value) = record.effort_level {
+ policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone());
}
if let Some(value) = record.idle_timeout_seconds {
policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string());
@@ -101,10 +119,41 @@ pub(super) fn build_launch_block(
policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value);
}
+ // B5 remote parity: when a canonical effort_level is persisted, strip
+ // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical
+ // value in policy_env (tier 1). In the k8s three-tier model tier 2
+ // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key
+ // must be absent from tier 2 whenever a canonical value is present.
+ // When effort_level is None there is no canonical to protect, so user
+ // env passthrough stands (env may legitimately seed startup effort).
+ //
+ // B2 remote parity: mirror the local A1 model authority. For a Claude
+ // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from
+ // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL
+ // alone (set above), and launch.env later-wins over policy_env. Left in
+ // launch.env, a user BUZZ_ACP_MODEL would introduce a second startup
+ // authority and a user ANTHROPIC_MODEL would silently override the
+ // canonical model. When no canonical model is present, neither key is in
+ // policy_env, so stripping them keeps the remote process free of both —
+ // matching local, where `apply_claude_model_env(None)` removes both.
+ let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false);
+ let strip_key = |k: &str| {
+ (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL"))
+ || (is_claude
+ && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL")
+ || k.eq_ignore_ascii_case("ANTHROPIC_MODEL")))
+ };
+ let launch_env: BTreeMap = descriptor
+ .env
+ .iter()
+ .filter(|(k, _)| !strip_key(k))
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+
serde_json::json!({
"command": descriptor.command,
"args": descriptor.args,
- "env": descriptor.env,
+ "env": launch_env,
"policy_env": policy_env,
"owner_pubkey": owner_pubkey,
})
@@ -284,13 +333,227 @@ mod tests {
assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name");
assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name");
assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt");
+ // goose runtime: model goes via BUZZ_ACP_MODEL (non-claude path).
assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model");
+ assert!(
+ launch["policy_env"]["ANTHROPIC_MODEL"].is_null(),
+ "goose must NOT receive ANTHROPIC_MODEL"
+ );
assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17");
assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23");
assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4");
assert_eq!(launch["owner_pubkey"], "owner-hex");
}
+ #[test]
+ fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() {
+ // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL,
+ // so the remote harness has a single startup model authority matching A1.
+ let record = record();
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::new(),
+ };
+ let teams: Vec = vec![];
+ let launch = build_launch_block(
+ &record,
+ &descriptor,
+ &teams,
+ None,
+ Some("claude-opus-4"),
+ "owner-hex",
+ );
+ assert_eq!(
+ launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4",
+ "claude remote must receive ANTHROPIC_MODEL"
+ );
+ assert!(
+ launch["policy_env"]["BUZZ_ACP_MODEL"].is_null(),
+ "claude remote must NOT receive BUZZ_ACP_MODEL"
+ );
+ }
+
+ /// F2: remote Claude launch must mirror local A1 — ALWAYS strip BOTH
+ /// BUZZ_ACP_MODEL and ANTHROPIC_MODEL from launch.env (tier 2), so the
+ /// canonical model in policy_env (tier 1) is the sole authority. Since
+ /// launch.env later-wins over policy_env, a user BUZZ_ACP_MODEL would add a
+ /// second startup authority and a user ANTHROPIC_MODEL would silently
+ /// override the canonical model.
+ #[test]
+ fn launch_block_claude_strips_both_model_keys_from_launch_env() {
+ let record = record();
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::from([
+ ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()),
+ ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()),
+ ("KEEP_ME".to_string(), "yes".to_string()),
+ ]),
+ };
+ let launch = build_launch_block(
+ &record,
+ &descriptor,
+ &[],
+ None,
+ Some("claude-opus-4"),
+ "owner-hex",
+ );
+
+ // Canonical model rides policy_env alone.
+ assert_eq!(launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4");
+ assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null());
+ // Both model keys are stripped from launch.env — neither can later-win.
+ assert!(
+ launch["env"]["BUZZ_ACP_MODEL"].is_null(),
+ "user BUZZ_ACP_MODEL must be stripped from launch.env for claude"
+ );
+ assert!(
+ launch["env"]["ANTHROPIC_MODEL"].is_null(),
+ "user ANTHROPIC_MODEL must be stripped from launch.env for claude"
+ );
+ // Unrelated user env survives.
+ assert_eq!(launch["env"]["KEEP_ME"], "yes");
+ }
+
+ /// F2: when no canonical model resolves, a Claude launch still strips both
+ /// model keys from launch.env, so neither authority reaches the remote
+ /// process — matching local `apply_claude_model_env(None)`, which removes
+ /// both.
+ #[test]
+ fn launch_block_claude_strips_model_keys_even_without_canonical() {
+ let record = record();
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::from([
+ ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()),
+ ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()),
+ ]),
+ };
+ let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
+
+ assert!(launch["policy_env"]["ANTHROPIC_MODEL"].is_null());
+ assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null());
+ assert!(
+ launch["env"]["BUZZ_ACP_MODEL"].is_null(),
+ "user BUZZ_ACP_MODEL must be stripped even without a canonical model"
+ );
+ assert!(
+ launch["env"]["ANTHROPIC_MODEL"].is_null(),
+ "user ANTHROPIC_MODEL must be stripped even without a canonical model"
+ );
+ }
+
+ /// F2: non-Claude runtimes must NOT strip model keys from launch.env — the
+ /// model authority stripping is Claude-specific (BUZZ_ACP_MODEL is the
+ /// spawn authority for other runtimes and rides policy_env there).
+ #[test]
+ fn launch_block_non_claude_preserves_user_model_env() {
+ let record = record(); // goose command
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "goose".into(),
+ args: vec![],
+ env: BTreeMap::from([("BUZZ_ACP_MODEL".to_string(), "user-model".to_string())]),
+ };
+ let launch =
+ build_launch_block(&record, &descriptor, &[], None, Some("model"), "owner-hex");
+
+ // goose puts canonical in policy_env, and the user launch.env value is
+ // preserved (later-wins is the intended goose behavior).
+ assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model");
+ assert_eq!(launch["env"]["BUZZ_ACP_MODEL"], "user-model");
+ }
+
+ #[test]
+ fn launch_block_claude_runtime_injects_effort_level_when_set() {
+ // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env.
+ let mut record = record();
+ record.effort_level = Some("high".to_string());
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::new(),
+ };
+ let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
+ assert_eq!(
+ launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high",
+ "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set"
+ );
+ }
+
+ #[test]
+ fn launch_block_does_not_inject_effort_level_when_absent() {
+ // I-4: no BUZZ_ACP_EFFORT_LEVEL in policy_env when record.effort_level is None.
+ let record = record(); // effort_level is None by default
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::new(),
+ };
+ let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
+ assert!(
+ launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(),
+ "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None"
+ );
+ }
+
+ /// B5 remote parity: when a canonical effort_level is persisted, a conflicting
+ /// user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env must NOT shadow it.
+ /// The canonical value in policy_env (tier 1) must win in the final build_env
+ /// output — the key must be absent from launch.env (tier 2) so tier 1 is
+ /// authoritative.
+ #[test]
+ fn launch_block_canonical_effort_strips_user_env_collision() {
+ let mut record = record();
+ record.effort_level = Some("high".to_string());
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ // User-supplied conflicting value in descriptor.env.
+ env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]),
+ };
+ let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
+
+ // Canonical must be in policy_env (tier 1).
+ assert_eq!(
+ launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high",
+ "canonical effort must be in policy_env when record.effort_level is Some"
+ );
+ // Conflicting user value must be absent from launch.env (tier 2) so it
+ // cannot shadow the canonical tier-1 value in build_env.
+ assert!(
+ launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(),
+ "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present"
+ );
+ }
+
+ /// B5 remote parity: when no canonical effort is persisted (effort_level is
+ /// None), a user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env survives
+ /// into launch.env — passthrough preserved for startup seeding.
+ #[test]
+ fn launch_block_user_effort_env_survives_when_no_canonical_value() {
+ let record = record(); // effort_level is None
+ let descriptor = EffectiveHarnessDescriptor {
+ command: "claude".into(),
+ args: vec![],
+ env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]),
+ };
+ let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
+
+ // No canonical — key must NOT appear in policy_env.
+ assert!(
+ launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(),
+ "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None"
+ );
+ // User value must survive in launch.env so the harness can use it.
+ assert_eq!(
+ launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "low",
+ "user-supplied effort must survive in launch.env when no canonical value"
+ );
+ }
+
/// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5"
/// even when the record's requested parallelism is 10. This is the direct
/// `launch.policy_env` seam test — the executable contract for remote providers.
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index 0176478bc5..61a2d8a145 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -59,6 +59,7 @@ fn bare_agent_record(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
+ effort_level: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs
index d15ee82abc..0cc5679bf7 100644
--- a/desktop/src-tauri/src/commands/identity_archive.rs
+++ b/desktop/src-tauri/src/commands/identity_archive.rs
@@ -12,17 +12,50 @@
//! see §Owner-of-Agent Requests and §Relay Processing Algorithm.
use serde::{Deserialize, Serialize};
-use tauri::State;
+use tauri::{AppHandle, State};
use crate::{
app_state::AppState,
events,
+ managed_agents::try_regenerate_nest,
relay::{
- classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override,
- submit_event, SubmitEventResponse,
+ classify_request_error, query_relay, query_relay_at, relay_api_base_url,
+ relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event,
+ workspace_relay_override, SubmitEventResponse,
},
};
+/// A relay target resolved from a single workspace-override read, so a caller
+/// that performs several relay requests cannot mix two relays if the workspace
+/// override changes mid-flight.
+///
+/// `relay_ws_url_with_override` and `relay_api_base_url_with_override` each read
+/// the override independently; a workspace switch between two such reads can
+/// pair one relay's NIP-11 signer with another relay's snapshot query.
+/// Capturing both fields from one read — matching those two functions' exact
+/// precedence, including the standalone `BUZZ_RELAY_HTTP` path when no override
+/// is set — guarantees the pair is internally consistent.
+pub(crate) struct RelayTarget {
+ /// Relay WebSocket URL (drives the NIP-11 fetch and the rendered footer).
+ pub ws_url: String,
+ /// Relay HTTP API base URL (drives `/query`).
+ pub api_base_url: String,
+}
+
+/// Capture the effective relay target once, before any network work.
+pub(crate) fn capture_relay_target(state: &AppState) -> RelayTarget {
+ match workspace_relay_override(state) {
+ Some(url) => RelayTarget {
+ api_base_url: relay_http_base_url(&url),
+ ws_url: url,
+ },
+ None => RelayTarget {
+ ws_url: relay_ws_url(),
+ api_base_url: relay_api_base_url(),
+ },
+ }
+}
+
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Read `target`'s live `kind:0` event and extract the first valid NIP-OA
@@ -139,44 +172,116 @@ pub struct UnarchiveRequest {
pub reason: Option,
}
-/// Submit a `kind:9035` archive request to the relay. Consent path is selected
-/// by the relay — we just attach the owner-of-agent `auth` tag when the live
-/// `kind:0` proves we own the target, so the relay can choose the `owner`
-/// path. Self and admin paths require no auth tag.
-#[tauri::command]
-pub async fn archive_identity(
- req: ArchiveRequest,
- state: State<'_, AppState>,
-) -> Result {
- let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?;
- let auth_ref = auth_tag.as_ref();
+/// Roster refresh a successful archive/unarchive triggers. Binding the action
+/// to a *type* rather than a closure selected at each call site is what closes
+/// the regression Thufir found: the command wrapper passes a value (`&app`)
+/// with no callback to construct, so the "regenerate on success" selection
+/// lives entirely inside the cores below — where the tests traverse it. The
+/// production binding is the single, irreducible `AppHandle` adapter.
+pub(crate) trait NestRegenTrigger {
+ fn trigger(&self);
+}
+impl NestRegenTrigger for AppHandle {
+ fn trigger(&self) {
+ try_regenerate_nest(self);
+ }
+}
+
+/// Submit `builder` to the active workspace relay, then trigger `on_success`
+/// exactly once iff the relay accepted the event.
+///
+/// This pins the shared half of the archive/unarchive → AGENTS.md-regeneration
+/// contract: regeneration is best-effort roster maintenance, so it must fire on
+/// a successful submission and must NOT fire when the submit is rejected (a
+/// rejected request changed nothing to re-render).
+async fn submit_then_regenerate(
+ builder: nostr::EventBuilder,
+ state: &AppState,
+ on_success: impl FnOnce(),
+) -> Result {
+ let response = submit_event(builder, state).await?;
+ on_success();
+ Ok(response)
+}
+
+/// `AppHandle`-free core of [`archive_identity`]: resolve the owner-of-agent
+/// `auth` tag, build the real `kind:9035` request, submit it, and trigger
+/// `regen` so a successful archive refreshes the roster.
+///
+/// The command wrapper is untestable (it needs a live Tauri runtime for its
+/// `AppHandle`), so this core owns the whole orchestration — including *binding*
+/// the regeneration trigger onto the successful-submit path. The wrapper only
+/// hands it the `AppHandle` as the trigger; a test drives the exact archive
+/// wiring with a counting trigger over a loopback relay. RED-on-revert: change
+/// `|| regen.trigger()` to `|| {}` here and
+/// `archive_core_fires_regen_only_on_accepted_submit` fails while the unarchive
+/// core test stays green.
+async fn archive_identity_core(
+ req: &ArchiveRequest,
+ state: &AppState,
+ regen: &impl NestRegenTrigger,
+) -> Result {
+ let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?;
let builder = events::build_archive_identity_request(
&req.target_pubkey,
&req.content,
req.reason.as_deref(),
req.replaced_by.as_deref(),
- auth_ref,
+ auth_tag.as_ref(),
)?;
- submit_event(builder, &state).await
+ submit_then_regenerate(builder, state, || regen.trigger()).await
}
-/// Submit a `kind:9036` unarchive request to the relay.
-#[tauri::command]
-pub async fn unarchive_identity(
- req: UnarchiveRequest,
- state: State<'_, AppState>,
+/// `AppHandle`-free core of [`unarchive_identity`]: builds the real `kind:9036`
+/// request and triggers `regen` on acceptance. See [`archive_identity_core`]
+/// for why this seam is extracted. RED-on-revert: change `|| regen.trigger()`
+/// to `|| {}` here and `unarchive_core_fires_regen_only_on_accepted_submit`
+/// fails while the archive core test stays green.
+async fn unarchive_identity_core(
+ req: &UnarchiveRequest,
+ state: &AppState,
+ regen: &impl NestRegenTrigger,
) -> Result {
- let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?;
- let auth_ref = auth_tag.as_ref();
-
+ let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?;
let builder = events::build_unarchive_identity_request(
&req.target_pubkey,
&req.content,
req.reason.as_deref(),
- auth_ref,
+ auth_tag.as_ref(),
)?;
- submit_event(builder, &state).await
+ submit_then_regenerate(builder, state, || regen.trigger()).await
+}
+
+/// Submit a `kind:9035` archive request to the relay. Consent path is selected
+/// by the relay — we just attach the owner-of-agent `auth` tag when the live
+/// `kind:0` proves we own the target, so the relay can choose the `owner`
+/// path. Self and admin paths require no auth tag.
+///
+/// On acceptance, refresh AGENTS.md so a just-archived agent drops from the
+/// roster without waiting for the next unrelated edit or app restart. The
+/// regen is fire-and-forget and fail-open like every other mutation site; it
+/// races the relay's kind:13535 snapshot update, so a stale render self-heals
+/// on the next regen.
+#[tauri::command]
+pub async fn archive_identity(
+ req: ArchiveRequest,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ archive_identity_core(&req, &state, &app).await
+}
+
+/// Submit a `kind:9036` unarchive request to the relay. See
+/// [`archive_identity`]: refresh the roster so an unarchived agent reappears
+/// promptly, fail-open against the same snapshot race.
+#[tauri::command]
+pub async fn unarchive_identity(
+ req: UnarchiveRequest,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ unarchive_identity_core(&req, &state, &app).await
}
/// If the current user is the verified NIP-OA owner of `target`, return the
@@ -228,8 +333,18 @@ struct RelayInformationDocument {
}
pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> {
- let relay_url = relay_ws_url_with_override(state);
- let http_url = relay_http_base_url(&relay_url);
+ fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await
+}
+
+/// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL
+/// instead of re-resolving the workspace override. Used by
+/// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot
+/// query belong to the same captured relay target.
+pub(crate) async fn fetch_relay_self_at(
+ state: &AppState,
+ relay_url: &str,
+) -> Result , String> {
+ let http_url = relay_http_base_url(relay_url);
let response = state
.http_client
.get(&http_url)
@@ -275,46 +390,71 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec {
.collect()
}
-/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
-/// caches this and tests membership client-side to drive the "Archived" flair.
+/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase
+/// hex pubkeys. Shared by the `list_archived_identities` command (frontend
+/// flair) and the backend nest regen (excluding archived agents from
+/// `AGENTS.md`).
///
/// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a
/// snapshot signed by the relay identity advertised in NIP-11 `self` can affect
-/// archive state. If the relay has no stable `self`, fail open with an empty
-/// snapshot rather than trusting unauthenticated relay-authoritative state.
-#[tauri::command]
-pub async fn list_archived_identities(
- state: State<'_, AppState>,
-) -> Result {
- let Some(relay_self) = fetch_relay_self(&state).await? else {
- return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
+/// archive state. Every failure path — no stable `self`, no snapshot, a bad
+/// signature or wrong author, or a query error — **fails open** with an empty
+/// set rather than trusting unauthenticated relay-authoritative state.
+pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec {
+ fetch_archived_pubkeys_at(state, &capture_relay_target(state)).await
+}
+
+/// Like [`fetch_archived_pubkeys`] but resolves both the NIP-11 signer and the
+/// snapshot query against one captured [`RelayTarget`] instead of re-reading
+/// the workspace override for each. This keeps a regeneration's advertised
+/// signer and its snapshot query on the same relay even if the workspace
+/// override changes between the two awaits.
+pub(crate) async fn fetch_archived_pubkeys_at(
+ state: &AppState,
+ target: &RelayTarget,
+) -> Vec {
+ let Ok(Some(relay_self)) = fetch_relay_self_at(state, &target.ws_url).await else {
+ return vec![];
};
- let events = query_relay(
- &state,
+ let query = query_relay_at(
+ state,
+ &target.api_base_url,
&[serde_json::json!({
"authors": [relay_self.clone()],
"kinds": [13535],
"limit": 1,
})],
)
- .await?;
+ .await;
+ let Ok(events) = query else {
+ return vec![];
+ };
let Some(snapshot) = events.into_iter().next() else {
- return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
+ return vec![];
};
// Defense-in-depth: the filter should already restrict author, but the
// client must still reject malformed or wrongly signed relay state.
if !snapshot.verify_id() || !snapshot.verify_signature() {
- return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
+ return vec![];
}
if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) {
- return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
+ return vec![];
}
+ archived_pubkeys_from_snapshot(&snapshot)
+}
+
+/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
+/// caches this and tests membership client-side to drive the "Archived" flair.
+#[tauri::command]
+pub async fn list_archived_identities(
+ state: State<'_, AppState>,
+) -> Result {
Ok(ArchivedIdentitiesSnapshot {
- archived: archived_pubkeys_from_snapshot(&snapshot),
+ archived: fetch_archived_pubkeys(&state).await,
})
}
@@ -336,6 +476,29 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
+ #[cfg(not(target_os = "windows"))]
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ /// Counting [`NestRegenTrigger`] double: records how many times the core
+ /// fires regeneration on the successful-submit path, standing in for the
+ /// production `AppHandle` binding without a live Tauri runtime.
+ #[cfg(not(target_os = "windows"))]
+ #[derive(Default)]
+ struct CountingRegen(AtomicUsize);
+
+ #[cfg(not(target_os = "windows"))]
+ impl CountingRegen {
+ fn count(&self) -> usize {
+ self.0.load(Ordering::SeqCst)
+ }
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ impl NestRegenTrigger for CountingRegen {
+ fn trigger(&self) {
+ self.0.fetch_add(1, Ordering::SeqCst);
+ }
+ }
/// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner.
fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event {
@@ -478,4 +641,223 @@ mod tests {
assert_eq!(minimal.content, "");
assert!(minimal.reason.is_none());
}
+
+ /// Regression for the cross-relay capture defect: `fetch_archived_pubkeys_at`
+ /// must resolve BOTH the NIP-11 signer and the `/query` snapshot against the
+ /// single captured [`RelayTarget`], never re-reading the live workspace
+ /// override. Two loopback relays advertise distinct signers and archive
+ /// distinct pubkeys; we capture relay A, then mutate the override to relay B
+ /// before the fetch. Because capture happens once up front, the override's
+ /// value at any later instant — including between the two archive awaits —
+ /// is irrelevant by construction, so setting it to B is the strongest form
+ /// of that perturbation. A must supply both the signer and the snapshot.
+ ///
+ /// RED-on-revert: restore `fetch_archived_pubkeys` to read the override for
+ /// each leg (`fetch_relay_self` + `query_relay`) and this returns B's pubkey.
+ #[tokio::test]
+ async fn archived_fetch_never_crosses_relays_mid_flight() {
+ use crate::app_state::build_app_state;
+ use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
+ use axum::{routing::get, routing::post, Json, Router};
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ // Build a loopback relay that advertises `relay_keys` as its NIP-11
+ // `self` and serves a relay-signed 13535 snapshot archiving `archived`.
+ async fn spawn_relay(relay_keys: Keys, archived: String) -> String {
+ let self_hex = relay_keys.public_key().to_hex();
+ let snapshot = EventBuilder::new(Kind::Custom(13535), "")
+ .tags([
+ Tag::parse(["-"]).unwrap(),
+ Tag::parse(["p", &archived]).unwrap(),
+ ])
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ let snapshot_json = serde_json::to_value(&snapshot).unwrap();
+
+ let router = Router::new()
+ .route(
+ "/",
+ get(move || {
+ let self_hex = self_hex.clone();
+ async move { Json(serde_json::json!({ "self": self_hex })) }
+ }),
+ )
+ .route(
+ "/query",
+ post(move || {
+ let snapshot_json = snapshot_json.clone();
+ async move { Json(serde_json::json!([snapshot_json])) }
+ }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ axum::serve(listener, router).await.ok();
+ });
+ format!("ws://{addr}")
+ }
+
+ let relay_a_keys = Keys::generate();
+ let relay_b_keys = Keys::generate();
+ // Distinct archived pubkeys, unrelated to either relay's signing key —
+ // nostr 0.37's EventBuilder silently drops a `p` tag that references the
+ // event's own signer, so the archived key must not equal the relay key.
+ let archived_on_a = Keys::generate().public_key().to_hex();
+ let archived_on_b = Keys::generate().public_key().to_hex();
+ let relay_a = spawn_relay(relay_a_keys, archived_on_a.clone()).await;
+ let relay_b = spawn_relay(relay_b_keys, archived_on_b.clone()).await;
+
+ let state = build_app_state();
+
+ // Capture relay A, then swap the override to relay B before the fetch.
+ *state.relay_url_override.lock().unwrap() = Some(relay_a.clone());
+ let target = capture_relay_target(&state);
+ *state.relay_url_override.lock().unwrap() = Some(relay_b.clone());
+
+ let archived = fetch_archived_pubkeys_at(&state, &target).await;
+
+ assert_eq!(
+ archived,
+ vec![archived_on_a],
+ "signer and snapshot must both come from the captured relay A, \
+ never the mutated override (relay B)"
+ );
+ reset_rate_limit_gate();
+ }
+
+ /// Spawn a loopback `/events` relay that answers every submit with the
+ /// given `accepted` verdict, so the archive/unarchive cores see a real
+ /// success or rejection over the wire. Returns the `ws://` base.
+ ///
+ /// The literal `/events` route below is why this file carries an
+ /// `EVENTS_INVENTORY` row (one occurrence, zero guard calls): a test
+ /// loopback, never a production egress site.
+ #[cfg(not(target_os = "windows"))]
+ async fn spawn_submit_relay(accepted: bool) -> String {
+ use axum::{routing::post, Json, Router};
+
+ let router = Router::new()
+ .route(
+ "/events",
+ post(move || async move {
+ Json(serde_json::json!({
+ "event_id": "e".repeat(64),
+ "accepted": accepted,
+ "message": if accepted { "" } else { "rejected by relay" },
+ }))
+ }),
+ )
+ // The cores resolve the owner-of-agent auth tag first, which reads
+ // the target's live kind:0; answer with an empty result set so that
+ // read resolves to "no owner tag" without a live upstream relay.
+ .route("/query", post(|| async { Json(serde_json::json!([])) }));
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ axum::serve(listener, router).await.ok();
+ });
+ format!("ws://{addr}")
+ }
+
+ /// Regression for the outsider-reported item 1, archive site: a successful
+ /// `kind:9035` archive MUST trigger nest regeneration, and a rejected
+ /// submit MUST NOT. This drives the production [`archive_identity_core`]
+ /// (the exact seam the command wrapper delegates to), forwarding a counting
+ /// hook against a loopback relay. RED-on-revert: replace the core's
+ /// forwarded `on_success` with `|| {}` and the "fires once" assertion fails;
+ /// this pins the archive command's callback independently of unarchive.
+ #[cfg(not(target_os = "windows"))]
+ #[tokio::test]
+ async fn archive_core_fires_regen_only_on_accepted_submit() {
+ use crate::app_state::build_app_state;
+ use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ let state = build_app_state();
+ let req = ArchiveRequest {
+ target_pubkey: Keys::generate().public_key().to_hex(),
+ content: String::new(),
+ reason: None,
+ replaced_by: None,
+ };
+
+ // Accepted archive → hook fires exactly once.
+ *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await);
+ let regen = CountingRegen::default();
+ let response = archive_identity_core(&req, &state, ®en)
+ .await
+ .expect("accepted archive returns Ok");
+ assert!(response.accepted);
+ assert_eq!(
+ regen.count(),
+ 1,
+ "an accepted archive must trigger regeneration exactly once"
+ );
+
+ // Rejected submit → error propagates, hook never fires.
+ *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await);
+ let regen = CountingRegen::default();
+ let result = archive_identity_core(&req, &state, ®en).await;
+ assert!(result.is_err(), "a rejected archive must return an error");
+ assert_eq!(
+ regen.count(),
+ 0,
+ "a rejected archive changed nothing, so regeneration must not fire"
+ );
+
+ reset_rate_limit_gate();
+ }
+
+ /// Regression for item 1, unarchive site: mirrors
+ /// [`archive_core_fires_regen_only_on_accepted_submit`] against the
+ /// `kind:9036` [`unarchive_identity_core`]. RED-on-revert: replace that
+ /// core's forwarded `on_success` with `|| {}` and this fails while the
+ /// archive test stays green — proving each command's callback is pinned
+ /// independently, not just the shared `submit_then_regenerate`.
+ #[cfg(not(target_os = "windows"))]
+ #[tokio::test]
+ async fn unarchive_core_fires_regen_only_on_accepted_submit() {
+ use crate::app_state::build_app_state;
+ use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ let state = build_app_state();
+ let req = UnarchiveRequest {
+ target_pubkey: Keys::generate().public_key().to_hex(),
+ content: String::new(),
+ reason: None,
+ };
+
+ // Accepted unarchive → hook fires exactly once.
+ *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await);
+ let regen = CountingRegen::default();
+ let response = unarchive_identity_core(&req, &state, ®en)
+ .await
+ .expect("accepted unarchive returns Ok");
+ assert!(response.accepted);
+ assert_eq!(
+ regen.count(),
+ 1,
+ "an accepted unarchive must trigger regeneration exactly once"
+ );
+
+ // Rejected submit → error propagates, hook never fires.
+ *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await);
+ let regen = CountingRegen::default();
+ let result = unarchive_identity_core(&req, &state, ®en).await;
+ assert!(result.is_err(), "a rejected unarchive must return an error");
+ assert_eq!(
+ regen.count(),
+ 0,
+ "a rejected unarchive changed nothing, so regeneration must not fire"
+ );
+
+ reset_rate_limit_gate();
+ }
}
diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
index f4590f5d6e..a4bbdeb677 100644
--- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
@@ -67,6 +67,7 @@ fn make_agent(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
+ effort_level: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
index c052622215..fbfede3588 100644
--- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
@@ -216,6 +216,7 @@ fn local_agent() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
index 6d7a2e6264..341426fe94 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
@@ -65,6 +65,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
index 7d3fd95ff3..75a1edea65 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
@@ -653,6 +653,7 @@ pub async fn confirm_agent_snapshot_import(
definition_respond_to_allowlist: minted.respond_to_allowlist.clone(),
definition_parallelism: minted_parallelism,
relay_mesh: None,
+ effort_level: None,
runtime: snapshot.definition.runtime.clone(),
name_pool: snapshot.definition.name_pool.clone(),
};
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
index 5e8cea52e6..fedb0e6058 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
@@ -74,6 +74,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
index 72bdca7de9..556127373b 100644
--- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
@@ -59,6 +59,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs
index 8315f39a36..e4c08a14be 100644
--- a/desktop/src-tauri/src/commands/team_snapshot.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot.rs
@@ -610,6 +610,7 @@ pub async fn confirm_team_snapshot_import(
definition_respond_to_allowlist: definition.respond_to_allowlist.clone(),
definition_parallelism: minted_parallelism,
relay_mesh: None,
+ effort_level: None,
runtime: member.definition.runtime.clone(),
name_pool: member.definition.name_pool.clone(),
};
diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
index a466228160..bec7f43bf8 100644
--- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
@@ -230,6 +230,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
runtime: None,
name_pool: vec![],
};
diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs
index 1513742bea..0c2a9573af 100644
--- a/desktop/src-tauri/src/egress_guard_tests.rs
+++ b/desktop/src-tauri/src/egress_guard_tests.rs
@@ -276,6 +276,10 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[
// Mock-relay route in its in-file tests; production publish goes through
// the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`).
("src/commands/personas/sharing.rs", 1, 0),
+ // Loopback submit relay in `identity_archive.rs`'s in-file regen tests;
+ // production archive/unarchive publish through the guarded boundary-1
+ // funnel via `submit_event`.
+ ("src/commands/identity_archive.rs", 1, 0),
];
// Needles are assembled at runtime so this scan file itself contains no
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index cefdccfd69..31d4ff3713 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -57,17 +57,16 @@ use deep_link::{
take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks,
PendingNavigationDeepLinks,
};
-use huddle::audio_output::{
- get_audio_output_device, list_audio_output_devices, set_audio_output_device,
-};
-use huddle::reconnect::reconnect_huddle_audio;
use huddle::{
- add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active,
- download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state,
- get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle,
- open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted,
- set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message,
- start_huddle, start_stt_pipeline, HuddlePhase,
+ add_agent_to_huddle,
+ audio_output::{get_audio_output_device, list_audio_output_devices, set_audio_output_device},
+ check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models,
+ end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode,
+ interrupt_huddle_speech, join_huddle, leave_huddle, open_huddle_window, push_audio_pcm,
+ reconnect::reconnect_huddle_audio,
+ remove_agent_from_huddle, set_huddle_manual_mic_unmuted, set_huddle_transcription_enabled,
+ set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline,
+ HuddlePhase,
};
use initial_window::*;
use managed_agents::{
@@ -786,6 +785,7 @@ pub fn run() {
get_baked_build_env_keys,
get_baked_build_env,
put_agent_session_config,
+ persist_agent_effort_level,
get_global_agent_config,
set_global_agent_config,
mesh_start_node,
diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs
index f70c714323..f0a4fabfed 100644
--- a/desktop/src-tauri/src/managed_agents/agent_events.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_events.rs
@@ -223,6 +223,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
index 5b51c52255..4b734ce159 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
@@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot";
/// this are stored as a URL reference instead.
const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB
+/// Maximum edge (px) for the PNG image body. The body is only a card
+/// thumbnail — the manifest keeps the full-resolution source reference — so a
+/// large avatar is downscaled here to keep the encoded snapshot well under
+/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap
+/// in `snapshotAvatarPng.ts`.
+const MAX_PNG_BODY_EDGE: u32 = 512;
+
/// Format discriminator — used for sniffing and validation.
pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot";
@@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png(
// there is no avatar or it cannot be decoded.
let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) {
Some(bytes) => {
- let encoded_avatar = if bytes.starts_with(b"\x89PNG") {
+ let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) {
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
})
@@ -449,6 +456,11 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result, S
}
/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk.
+///
+/// The decoded image is downscaled so its longest edge is at most
+/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card
+/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from
+/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`.
fn transcode_avatar_to_png_with_text(
avatar_bytes: &[u8],
keyword: &str,
@@ -456,6 +468,7 @@ fn transcode_avatar_to_png_with_text(
) -> Result, String> {
let image = image::load_from_memory(avatar_bytes)
.map_err(|e| format!("Failed to decode avatar image: {e}"))?;
+ let image = downscale_to_body_cap(image);
let mut png_bytes = Vec::new();
image
.write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png)
@@ -463,6 +476,32 @@ fn transcode_avatar_to_png_with_text(
inject_text_chunk(&png_bytes, keyword, text)
}
+/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving
+/// aspect ratio. Images already within the cap are returned untouched.
+fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage {
+ if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE {
+ return image;
+ }
+ image.resize(
+ MAX_PNG_BODY_EDGE,
+ MAX_PNG_BODY_EDGE,
+ image::imageops::FilterType::Lanczos3,
+ )
+}
+
+/// Whether an already-PNG avatar is within the body dimension cap and can be
+/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded
+/// and downscaled. Undecodable headers fall through to the transcode path.
+fn png_within_body_cap(png_bytes: &[u8]) -> bool {
+ Decoder::new(Cursor::new(png_bytes))
+ .read_info()
+ .map(|reader| {
+ let info = reader.info();
+ info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE
+ })
+ .unwrap_or(false)
+}
+
/// Inject a tEXt chunk into an existing PNG by re-encoding it.
///
/// Re-decodes the image data via the `png` crate and writes a fresh PNG with
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
index 751452aa7b..de2f71577a 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
@@ -417,6 +417,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
index fca15111d0..9f234749bc 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
@@ -73,6 +73,7 @@ fn minimal_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
+ effort_level: None,
}
}
@@ -233,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}
-// ── PNG memory parity ─────────────────────────────────────────────────────
+#[test]
+fn png_snapshot_downscales_oversize_avatar_under_cap() {
+ // A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB)
+ // must be downscaled for the PNG body so the snapshot stays under the
+ // 10 MiB cap — while the manifest keeps the untouched source reference.
+ // An already-PNG oversize avatar exercises the `png_within_body_cap` guard
+ // that routes it through the downscaling transcode path.
+ let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| {
+ image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8])
+ }));
+ let mut source_bytes = Vec::new();
+ avatar
+ .write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png)
+ .unwrap();
+
+ let snapshot = build_snapshot(
+ &minimal_record(),
+ MemoryLevel::None,
+ vec![],
+ Some(&source_bytes),
+ );
+ let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap();
+
+ assert!(
+ png_bytes.len()
+ <= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4,
+ "downscaled snapshot ({} bytes) must be far under the 10 MiB cap",
+ png_bytes.len()
+ );
+
+ let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap();
+ let (width, height) = (reader.info().width, reader.info().height);
+ assert!(
+ width <= 512 && height <= 512,
+ "body dimensions {width}×{height} must fit the 512px cap"
+ );
+ // Aspect ratio preserved: the longest edge (height) is clamped to the cap.
+ assert_eq!(height, 512, "longest edge should hit the 512px cap");
+
+ // The manifest keeps the untouched full-resolution source reference — only
+ // the PNG body is downscaled. The oversize source bytes exceed the inline
+ // cap, so the manifest falls back to the record's `avatar_url`.
+ let manifest =
+ decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap();
+ assert_eq!(
+ manifest.profile.avatar_url.as_deref(),
+ Some("https://example.com/avatar.png"),
+ "manifest must preserve the untouched source avatar reference"
+ );
+ assert!(
+ manifest.profile.avatar_data_url.is_none(),
+ "oversize source bytes must not be inlined into the manifest"
+ );
+}
#[test]
fn png_round_trip_with_core_memory() {
diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs
new file mode 100644
index 0000000000..647ea56209
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs
@@ -0,0 +1,53 @@
+//! Claude Code agent spawn-time env helpers.
+//!
+//! A1 contract: `ANTHROPIC_MODEL` is the single startup model authority for
+//! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned
+//! env so the harness never sees two model authorities simultaneously.
+//!
+//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup
+//! effort authority for all local agents. Written after `descriptor.env` so
+//! user-supplied entries cannot shadow a persisted canonical value.
+
+/// The spawn-time env var carrying startup effort. Shared by the spawn
+/// application ([`apply_effort_env`]) and the snapshot projection
+/// (`spawn_snapshot::effective_effort`) so the value the harness receives and
+/// the value the restart badge compares are named from one place.
+pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL";
+
+/// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model`
+/// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env.
+///
+/// Must be called after `descriptor.env` is written so that any user-supplied
+/// `ANTHROPIC_MODEL` is overridden by the Buzz-resolved value.
+pub fn apply_claude_model_env(command: &mut std::process::Command, effective_model: Option<&str>) {
+ // Remove BUZZ_ACP_MODEL — the catalog-switch path is for live ACP switches
+ // only; at spawn time ANTHROPIC_MODEL is the sole authority.
+ command.env_remove("BUZZ_ACP_MODEL");
+ match effective_model {
+ Some(m) => {
+ command.env("ANTHROPIC_MODEL", m);
+ }
+ None => {
+ command.env_remove("ANTHROPIC_MODEL");
+ }
+ }
+}
+
+/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from
+/// `effort_level` (or leave it untouched if `None`).
+///
+/// Must be called after `descriptor.env` is written so the canonical persisted
+/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When
+/// `effort_level` is `None` there is no canonical value to assert; the command
+/// env is left untouched so a user-supplied value from `descriptor.env`
+/// legitimately seeds startup effort.
+pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) {
+ if let Some(e) = effort_level {
+ command.env(EFFORT_LEVEL_ENV_VAR, e);
+ }
+ // None: no canonical value — leave whatever descriptor.env wrote intact.
+}
+
+#[cfg(test)]
+#[path = "tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs
new file mode 100644
index 0000000000..f6f0f90cb2
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs
@@ -0,0 +1,127 @@
+use super::{apply_claude_model_env, apply_effort_env};
+
+/// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after
+/// `apply_claude_model_env`, even if it was set before (dual-authority defect).
+/// ANTHROPIC_MODEL must be set to the resolved model.
+#[test]
+fn a1_buzz_acp_model_absent_anthropic_model_present_after_env_apply() {
+ let mut cmd = std::process::Command::new("true");
+ // Simulate descriptor.env writing BUZZ_ACP_MODEL (the pre-A1 path).
+ cmd.env("BUZZ_ACP_MODEL", "claude-opus-4");
+ apply_claude_model_env(&mut cmd, Some("claude-opus-4"));
+
+ let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect();
+
+ // BUZZ_ACP_MODEL must be removed. Command::get_envs returns None for
+ // explicitly-removed keys.
+ let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL"));
+ assert!(
+ buzz_acp.is_none() || buzz_acp.unwrap().is_none(),
+ "BUZZ_ACP_MODEL must be absent (or explicitly removed) after A1 policy"
+ );
+
+ // ANTHROPIC_MODEL must be set to the resolved model value.
+ let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL"));
+ assert!(anthropic.is_some(), "ANTHROPIC_MODEL must be present");
+ assert_eq!(
+ anthropic.unwrap().unwrap_or_default(),
+ "claude-opus-4",
+ "ANTHROPIC_MODEL must equal the effective model"
+ );
+}
+
+/// A1: when no model is resolved, ANTHROPIC_MODEL must be removed so Claude
+/// uses its own default rather than inheriting a stale env value.
+#[test]
+fn a1_anthropic_model_removed_when_no_effective_model() {
+ let mut cmd = std::process::Command::new("true");
+ // Pre-set a stale value that might have leaked in.
+ cmd.env("ANTHROPIC_MODEL", "claude-3-5-sonnet");
+ cmd.env("BUZZ_ACP_MODEL", "claude-3-5-sonnet");
+ apply_claude_model_env(&mut cmd, None);
+
+ let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect();
+
+ let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL"));
+ assert!(
+ anthropic.is_none() || anthropic.unwrap().is_none(),
+ "ANTHROPIC_MODEL must be absent when no effective model"
+ );
+ let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL"));
+ assert!(
+ buzz_acp.is_none() || buzz_acp.unwrap().is_none(),
+ "BUZZ_ACP_MODEL must always be absent after A1 policy"
+ );
+}
+
+// ── B5 effort-authority contract tests ──────────────────────────────────────
+//
+// These tests verify that `apply_effort_env`, called after `descriptor.env`,
+// makes the canonical persisted effort win over any user-supplied value.
+
+/// B5 (local): canonical effort wins when user env supplies a conflicting value.
+/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low,
+/// then apply_effort_env is called with the canonical "high". The canonical value
+/// must be what survives in the spawned-child env.
+#[test]
+fn b5_canonical_effort_wins_over_user_env_collision() {
+ let mut cmd = std::process::Command::new("true");
+ // Simulate descriptor.env writing a user-supplied value (the pre-fix
+ // ordering: effort written before the loop, then loop overwrote it, or
+ // equivalently: effort written post-loop but with user value also post-loop).
+ cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low");
+
+ // Post-loop canonical application — the fix.
+ apply_effort_env(&mut cmd, Some("high"));
+
+ let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect();
+ let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL"));
+ assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present");
+ assert_eq!(
+ effort.unwrap().unwrap_or_default(),
+ "high",
+ "canonical effort must win over the user-supplied 'low' — B5 authority ordering"
+ );
+}
+
+/// B5 (local): when no canonical effort is persisted (effort_level is None),
+/// user env passthrough is preserved — the descriptor.env entry seeds startup effort.
+/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command),
+/// then apply_effort_env(None) is called — user value must survive.
+#[test]
+fn b5_user_effort_env_survives_when_no_canonical_value() {
+ let mut cmd = std::process::Command::new("true");
+ // Simulate descriptor.env loop having written a user-supplied value first.
+ cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low");
+
+ // No canonical value — apply_effort_env(None) is a no-op so the user
+ // value already written by the descriptor.env loop survives intact.
+ apply_effort_env(&mut cmd, None);
+
+ let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect();
+ let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL"));
+ assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present");
+ assert_eq!(
+ effort.unwrap().unwrap_or_default(),
+ "low",
+ "user-supplied effort must survive when no canonical value is persisted"
+ );
+}
+
+/// B5 (local): canonical effort is present in the spawned env even when user
+/// env did NOT supply a conflicting value (basic injection contract).
+#[test]
+fn b5_canonical_effort_injected_when_no_user_collision() {
+ let mut cmd = std::process::Command::new("true");
+ // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env.
+ apply_effort_env(&mut cmd, Some("medium"));
+
+ let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect();
+ let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL"));
+ assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present");
+ assert_eq!(
+ effort.unwrap().unwrap_or_default(),
+ "medium",
+ "canonical effort must be injected when no collision"
+ );
+}
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs
index 449197a3b3..b54297df80 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs
@@ -1,10 +1,28 @@
use super::types::{ExtensionEntry, RuntimeFileConfig};
-/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`.
-pub(super) fn read_config_file() -> Option {
+/// Read Claude Code config from `settings.json` and `.claude.json`.
+///
+/// `config_dir` — when `Some`, reads both `settings.json` and `.claude.json`
+/// from that directory (the agent's effective `CLAUDE_CONFIG_DIR`).
+/// Defaults to `~/.claude/settings.json` and `~/.claude.json` when `None`.
+///
+/// Both files are resolved from the same directory: the claude 2.1.x binary
+/// resolves `.claude.json` as `join(process.env.CLAUDE_CONFIG_DIR || homedir(),
+/// ".claude.json")`, mirroring the `settings.json` resolver. A user-set
+/// `CLAUDE_CONFIG_DIR` therefore remaps both files — honoring only
+/// `settings.json` would misrepresent the agent's actual MCP config.
+pub(super) fn read_config_file(config_dir: Option<&std::path::Path>) -> Option {
let home = dirs::home_dir()?;
- let settings_path = home.join(".claude").join("settings.json");
- let mcp_path = home.join(".claude.json");
+
+ // #3493: honor user-set CLAUDE_CONFIG_DIR for both settings.json and
+ // .claude.json — the binary resolves both relative to CLAUDE_CONFIG_DIR.
+ // Panel reflects the actual config the agent reads.
+ let settings_path = config_dir
+ .map(|d| d.join("settings.json"))
+ .unwrap_or_else(|| home.join(".claude").join("settings.json"));
+ let mcp_path = config_dir
+ .map(|d| d.join(".claude.json"))
+ .unwrap_or_else(|| home.join(".claude.json"));
let settings = read_json_file(&settings_path);
let mcp_config = read_json_file(&mcp_path);
@@ -74,6 +92,22 @@ mod tests {
}
}
+ /// #3493: read_config_file(Some(dir)) must read settings.json from the
+ /// custom dir, not ~/.claude/settings.json — proves CLAUDE_CONFIG_DIR
+ /// actually remaps the settings read (not just the reported MCP path).
+ #[test]
+ fn reads_settings_from_custom_config_dir() {
+ use std::io::Write;
+ let dir = tempfile::tempdir().unwrap();
+ let mut f = std::fs::File::create(dir.path().join("settings.json")).unwrap();
+ f.write_all(br#"{"model": "claude-opus-4", "effortLevel": "high"}"#)
+ .unwrap();
+
+ let cfg = read_config_file(Some(dir.path())).expect("settings.json in custom dir is read");
+ assert_eq!(cfg.model.as_deref(), Some("claude-opus-4"));
+ assert_eq!(cfg.thinking_effort.as_deref(), Some("high"));
+ }
+
#[test]
fn parse_model_from_settings() {
let cfg = parse_settings(r#"{"model": "claude-sonnet-4-20250514"}"#);
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs
index c51f325cf3..93827635e9 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs
@@ -9,11 +9,16 @@ use super::types::*;
/// persona and global tiers assembled at the command boundary. Each field
/// builder constructs its own candidate list and resolves via
/// `resolve_with_override`.
+///
+/// `claude_config_dir` — when `Some`, the panel reads claude `settings.json`
+/// and `.claude.json` from that directory (the agent's effective
+/// `CLAUDE_CONFIG_DIR`) instead of `~/.claude/`. Ignored for non-claude runtimes.
pub(crate) fn read_config_surface(
record: &ManagedAgentRecord,
runtime_meta: Option<&KnownAcpRuntime>,
session_cache: Option<&SessionConfigCache>,
tiers: &InheritedConfigTiers,
+ claude_config_dir: Option<&std::path::Path>,
) -> RuntimeConfigSurface {
let is_pre_spawn = session_cache.is_none();
@@ -22,7 +27,7 @@ pub(crate) fn read_config_surface(
.map(|m| m.id)
.and_then(|id| match id {
"goose" => super::goose::read_config_file().map(|c| (c, true)),
- "claude" => super::claude::read_config_file().map(|c| (c, true)),
+ "claude" => super::claude::read_config_file(claude_config_dir).map(|c| (c, true)),
"codex" => super::codex::read_config_file().map(|c| (c, true)),
"buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)),
_ => None,
@@ -49,7 +54,14 @@ pub(crate) fn read_config_surface(
.or_else(|| find_config_option_value(c, "model"))
});
let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode"));
- let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort"));
+
+ // B5: the adapter-advertised effort control, selected ONCE by its category.
+ // The adapter defines it as category `thought_level` with its own config id
+ // (Claude Code emits `id="effort"`); reading by the literal category `effort`
+ // would miss it entirely. The running value, the write config id, and the
+ // picker options all derive from this single entry.
+ let effort_option = session_cache.and_then(find_effort_option);
+ let acp_effort = effort_option.and_then(|o| o.current_value.clone());
let model_overridden = session_cache.is_some_and(|c| c.model_overridden);
@@ -79,9 +91,9 @@ pub(crate) fn read_config_surface(
record,
&file_config.thinking_effort,
&acp_effort,
+ effort_option.map(|o| o.config_id.as_str()),
thinking_env_var,
is_pre_spawn,
- session_cache,
tiers,
),
max_output_tokens: build_numeric_env_field(
@@ -145,10 +157,9 @@ pub(crate) fn read_config_surface(
});
}
- let config_file_path = runtime_meta
- .and_then(|m| m.config_file_path)
- .map(resolve_tilde);
- let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime);
+ let config_file_path = config_file_path_for_runtime(runtime_meta, claude_config_dir);
+ let mcp_config_file_path =
+ runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, claude_config_dir));
let extensions = file_config.extensions.clone();
let sources = ConfigSourceReport {
@@ -181,6 +192,12 @@ pub(crate) fn read_config_surface(
mcp_config_file_path,
};
+ // B5: the adapter-advertised effort control, discovered once above. The UI
+ // uses `effort_config_id` to send `set_config_option` and renders
+ // `effort_options` instead of hardcoded values (never hardcoded here).
+ let effort_config_id = effort_option.map(|o| o.config_id.clone());
+ let effort_options = effort_option.map(|o| o.options.clone()).unwrap_or_default();
+
RuntimeConfigSurface {
runtime_id: runtime_meta.map(|m| m.id.to_string()),
runtime_label: runtime_meta.map(|m| m.label.to_string()),
@@ -189,15 +206,52 @@ pub(crate) fn read_config_surface(
advanced,
extensions,
sources,
+ claude_config_dir_custom: claude_config_dir.is_some(),
+ effort_config_id,
+ effort_options,
}
}
-fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option {
+/// Resolve the reported `settings.json` path. #3493: for a claude agent with a
+/// custom `CLAUDE_CONFIG_DIR`, the reader reads `/settings.json`, so the
+/// reported path must point there — not the static `~/.claude/settings.json`
+/// from the runtime metadata. All other runtimes (and claude with no custom
+/// dir) use the static metadata path.
+fn config_file_path_for_runtime(
+ runtime_meta: Option<&KnownAcpRuntime>,
+ claude_config_dir: Option<&std::path::Path>,
+) -> Option {
+ let runtime = runtime_meta?;
+ if runtime.id == "claude" {
+ if let Some(dir) = claude_config_dir {
+ return Some(dir.join("settings.json").to_string_lossy().into_owned());
+ }
+ }
+ runtime.config_file_path.map(resolve_tilde)
+}
+
+fn mcp_config_file_path_for_runtime(
+ runtime: &KnownAcpRuntime,
+ claude_config_dir: Option<&std::path::Path>,
+) -> Option {
match runtime.id {
"goose" => {
super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned())
}
- "claude" => Some(resolve_tilde("~/.claude.json")),
+ // #3493: the claude 2.1.x binary resolves .claude.json as
+ // join(CLAUDE_CONFIG_DIR || homedir(), ".claude.json"), so the MCP
+ // config file moves with a user-set CLAUDE_CONFIG_DIR.
+ "claude" => Some(
+ claude_config_dir
+ .map(|d| d.join(".claude.json"))
+ .unwrap_or_else(|| {
+ dirs::home_dir()
+ .map(|h| h.join(".claude.json"))
+ .unwrap_or_default()
+ })
+ .to_string_lossy()
+ .into_owned(),
+ ),
"codex" => {
super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned())
}
@@ -486,12 +540,20 @@ fn build_thinking_field(
record: &ManagedAgentRecord,
file_effort: &Option,
acp_effort: &Option,
+ effort_config_id: Option<&str>,
thinking_env_var: Option<&str>,
is_pre_spawn: bool,
- session_cache: Option<&SessionConfigCache>,
tiers: &InheritedConfigTiers,
) -> Option {
- // Tier ordering: record env > ACP > persona env > global env > definition env > config file.
+ // Tier ordering:
+ // record env > record.effort_level (canonical Buzz-persisted) > ACP >
+ // persona env > global env > definition env > config file.
+ //
+ // `record.effort_level` is the B5 canonical value: the effort a spawn will
+ // actually apply at next session start (via `apply_effort_env`). Sitting it
+ // above ACP means the panel shows the *configured* value the agent will
+ // launch with rather than a stale live-session reading — the record can't
+ // be masked by, nor mask, the running value silently.
let [rec_env, pers_env, glob_env, def_env] = thinking_env_var
.map(|k| {
env_candidates(
@@ -504,8 +566,11 @@ fn build_thinking_field(
})
.unwrap_or([None, None, None, None]);
+ let canonical_effort = record.effort_level.as_deref();
+
let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[
(rec_env, ConfigOrigin::BuzzExplicit),
+ (canonical_effort, ConfigOrigin::BuzzExplicit),
(acp_effort.as_deref(), ConfigOrigin::AcpConfigOption),
(pers_env, ConfigOrigin::PersonaDefault),
(glob_env, ConfigOrigin::GlobalDefault),
@@ -514,16 +579,14 @@ fn build_thinking_field(
];
let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?;
- let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") {
- ConfigWriteMechanism::AcpSetConfigOption {
- config_id: "effort".to_string(),
- }
- } else if let Some(env_key) = thinking_env_var {
- ConfigWriteMechanism::RespawnWithEnvVar {
+ let write_via = match (is_pre_spawn, effort_config_id, thinking_env_var) {
+ (false, Some(config_id), _) => ConfigWriteMechanism::AcpSetConfigOption {
+ config_id: config_id.to_string(),
+ },
+ (_, _, Some(env_key)) => ConfigWriteMechanism::RespawnWithEnvVar {
env_key: env_key.to_string(),
- }
- } else {
- ConfigWriteMechanism::ReadOnly
+ },
+ _ => ConfigWriteMechanism::ReadOnly,
};
Some(NormalizedField {
@@ -677,6 +740,19 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio
.and_then(|o| o.current_value.clone())
}
+/// Selects the adapter-advertised effort control from the session cache.
+///
+/// The adapter emits effort under category `thought_level` with its own
+/// config id (Claude Code uses `id="effort"`). Selecting by category — not by
+/// a hardcoded id — is what lets the running value, the write config id, and
+/// the picker options all derive from one entry.
+fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> {
+ cache
+ .config_options
+ .iter()
+ .find(|o| o.category.as_deref() == Some("thought_level"))
+}
+
fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool {
cache.is_some_and(|c| {
c.config_options
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
index 0e7070724d..36b6022b53 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
@@ -116,6 +116,7 @@ fn test_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
@@ -168,7 +169,7 @@ fn persona_and_global_env_tiers(
fn pre_spawn_surface_reports_pending_acp_tiers() {
let record = test_record();
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
assert!(surface.is_pre_spawn);
assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending);
@@ -184,7 +185,7 @@ fn surface_reports_mcp_specific_config_path() {
let record = test_record();
let runtime = test_runtime();
let surface = with_goose_path_root(None, || {
- read_config_surface(&record, Some(runtime), None, &no_tiers())
+ read_config_surface(&record, Some(runtime), None, &no_tiers(), None)
});
let path = surface
@@ -203,7 +204,7 @@ fn goose_mcp_config_path_follows_path_root_override() {
let record = test_record();
let runtime = test_runtime();
let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || {
- read_config_surface(&record, Some(runtime), None, &no_tiers())
+ read_config_surface(&record, Some(runtime), None, &no_tiers(), None)
});
let expected_path = Path::new("/tmp/buzz-goose-root")
@@ -227,7 +228,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() {
config_file_path: Some("~/.claude/settings.json"),
..*test_runtime()
};
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
assert!(surface
.sources
@@ -247,7 +248,7 @@ fn record_model_overrides_file_model() {
record.model = Some("explicit-model".to_string());
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("explicit-model"));
assert_eq!(model.origin, ConfigOrigin::BuzzExplicit);
@@ -260,7 +261,7 @@ fn provider_locked_shows_locked() {
provider_locked: true,
..*test_runtime()
};
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let provider = surface.normalized.provider.unwrap();
assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)"));
assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint);
@@ -286,7 +287,7 @@ fn post_spawn_with_model_config_option_uses_acp() {
captured_at: "".to_string(),
};
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None);
assert!(!surface.is_pre_spawn);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("claude-opus-4"));
@@ -310,7 +311,7 @@ fn acp_model_overrides_file_model_with_override_tracking() {
captured_at: "".to_string(),
};
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("acp-model"));
assert_eq!(model.origin, ConfigOrigin::AcpConfigOption);
@@ -331,7 +332,7 @@ fn persona_model_tier_produces_persona_default_origin() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("persona-model"));
@@ -347,7 +348,7 @@ fn global_model_tier_produces_global_default_origin() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("global-model"));
@@ -363,7 +364,7 @@ fn persona_provider_tier_produces_persona_default_origin() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let provider = surface.normalized.provider.unwrap();
assert_eq!(provider.value.as_deref(), Some("anthropic"));
@@ -379,7 +380,7 @@ fn persona_prompt_tier_produces_persona_default_origin() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let prompt = surface.normalized.system_prompt.unwrap();
assert_eq!(
@@ -416,7 +417,7 @@ fn runtime_override_wins_display_when_model_overridden_is_true() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers);
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None);
let model = surface.normalized.model.unwrap();
// Override wins the display value with a runtime-override origin.
@@ -448,7 +449,7 @@ fn no_runtime_override_when_model_overridden_is_false() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers);
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None);
let model = surface.normalized.model.unwrap();
// model_overridden is false => the override branch is not taken.
@@ -480,7 +481,7 @@ fn no_false_positive_override_when_persona_edited_mid_life() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers);
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None);
let model = surface.normalized.model.unwrap();
// model_overridden is false => no RuntimeOverride, even though
@@ -539,7 +540,7 @@ fn explicit_record_model_not_retagged_when_already_present() {
record.model = Some("explicit-model".to_string());
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("explicit-model"));
@@ -562,7 +563,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() {
.insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string());
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect();
assert!(
@@ -601,7 +602,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() {
.insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string());
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect();
assert!(
@@ -662,7 +663,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() {
);
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let field = surface.normalized.max_output_tokens.unwrap();
assert_eq!(field.value.as_deref(), Some("8192"));
@@ -683,7 +684,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() {
);
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let field = surface.normalized.context_limit.unwrap();
assert_eq!(field.value.as_deref(), Some("100000"));
@@ -701,7 +702,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() {
let record = test_record();
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
assert!(
surface.normalized.max_output_tokens.is_none(),
@@ -726,7 +727,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() {
);
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect();
assert!(
@@ -747,7 +748,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() {
.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string());
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let field = surface.normalized.thinking_effort.unwrap();
assert_eq!(field.value.as_deref(), Some("high"));
@@ -768,7 +769,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() {
);
let runtime = buzz_agent_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect();
assert!(
@@ -830,7 +831,7 @@ fn global_effort_surfaces_as_global_default_when_record_has_none() {
let runtime = buzz_agent_rt();
let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let effort = surface
.normalized
@@ -847,7 +848,7 @@ fn persona_effort_shadows_global_and_tags_persona_default() {
let runtime = buzz_agent_rt();
let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let effort = surface
.normalized
@@ -871,7 +872,7 @@ fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() {
let runtime = buzz_agent_rt();
let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let effort = surface
.normalized
@@ -887,7 +888,7 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() {
let record = test_record();
let runtime = buzz_agent_rt();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
assert!(
surface.normalized.thinking_effort.is_none(),
@@ -897,6 +898,9 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() {
/// AC-5 (conflicting-ACP): inherited effort set (global=high) + live ACP effort=low
/// → ACP wins as primary (AcpConfigOption), global is the overridden secondary.
+///
+/// The ACP entry uses the real adapter shape: category `thought_level` with an
+/// adapter-defined config id (`effort`), NOT category `effort`.
#[test]
fn acp_effort_wins_over_inherited_global_effort_as_secondary() {
let record = test_record();
@@ -904,7 +908,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() {
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "effort".to_string(),
- category: Some("effort".to_string()),
+ category: Some("thought_level".to_string()),
display_name: Some("Effort".to_string()),
current_value: Some("low".to_string()),
options: vec![],
@@ -918,7 +922,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() {
};
let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high");
- let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers);
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None);
let effort = surface
.normalized
@@ -942,7 +946,7 @@ fn numeric_max_tokens_inherits_from_global_env() {
let runtime = buzz_agent_runtime();
let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let field = surface.normalized.max_output_tokens.unwrap();
assert_eq!(field.value.as_deref(), Some("16384"));
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs
index 8613124f25..f86793f91a 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs
@@ -16,7 +16,7 @@ fn numeric_context_limit_inherits_from_persona_env() {
let runtime = buzz_agent_runtime();
let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let field = surface.normalized.context_limit.unwrap();
assert_eq!(field.value.as_deref(), Some("200000"));
@@ -33,7 +33,7 @@ fn record_max_tokens_overrides_global_env_with_secondary() {
let runtime = buzz_agent_runtime();
let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let field = surface.normalized.max_output_tokens.unwrap();
assert_eq!(field.value.as_deref(), Some("8192"));
@@ -64,7 +64,7 @@ fn global_env_prompt_wins_over_persona_structured_prompt() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let prompt = surface.normalized.system_prompt.unwrap();
assert_eq!(prompt.value.as_deref(), Some("global-env-prompt"));
@@ -87,7 +87,7 @@ fn persona_env_model_wins_over_persona_structured_model() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
// persona env outranks persona struct because env candidates precede struct
@@ -106,7 +106,7 @@ fn structured_fallback_intact_when_no_env_representation() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("struct-persona-model"));
@@ -130,7 +130,7 @@ fn post_sanitization_empty_global_env_falls_through_to_persona_tier() {
// No global env (stripped); persona provides the valid fallback.
let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium");
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
// Persona value surfaces instead of the stripped global value.
let effort = surface.normalized.thinking_effort.unwrap();
@@ -157,7 +157,7 @@ fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() {
);
let runtime = test_runtime();
- let surface = read_config_surface(&record, Some(runtime), None, &no_tiers());
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
let prompt = surface.normalized.system_prompt.unwrap();
assert_eq!(prompt.value.as_deref(), Some("env-prompt-B"));
@@ -189,7 +189,7 @@ fn definition_env_beats_structured_persona_model() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("harness-model"));
@@ -222,7 +222,7 @@ fn global_env_beats_definition_env() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("global-model"));
@@ -249,10 +249,272 @@ fn reserved_key_absent_from_definition_env_falls_through() {
..Default::default()
};
- let surface = read_config_surface(&record, Some(runtime), None, &tiers);
+ let surface = read_config_surface(&record, Some(runtime), None, &tiers, None);
let model = surface.normalized.model.unwrap();
// Falls through to persona structured model.
assert_eq!(model.value.as_deref(), Some("persona-struct-model"));
assert_eq!(model.origin, ConfigOrigin::PersonaDefault);
}
+
+// ── B4/B5 canonical effort_level tier tests ────────────────────────────────
+//
+// record.effort_level is the Buzz-canonical seeded value (the effort a spawn
+// applies at next session start via `apply_effort_env`). It must surface as
+// BuzzExplicit and take precedence over the config-file tier, but not over a
+// record env var override.
+
+/// B4: record.effort_level surfaces as BuzzExplicit when no env var is set.
+#[test]
+fn b4_canonical_effort_level_surfaces_as_buzz_explicit() {
+ let mut record = test_record();
+ record.effort_level = Some("high".to_string());
+ let runtime = buzz_agent_runtime();
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+ let effort = surface
+ .normalized
+ .thinking_effort
+ .expect("effort must surface from canonical record tier");
+ assert_eq!(effort.value.as_deref(), Some("high"));
+ assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
+}
+
+/// B4: record.effort_level shadows the config-file tier.
+#[test]
+fn b4_canonical_effort_level_shadows_file_tier() {
+ let mut record = test_record();
+ record.effort_level = Some("medium".to_string());
+ // No env var set — the config-file tier would win if canonical were absent.
+ let runtime = buzz_agent_runtime();
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+ let effort = surface
+ .normalized
+ .thinking_effort
+ .expect("canonical effort must shadow file tier");
+ assert_eq!(effort.value.as_deref(), Some("medium"));
+ assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
+}
+
+/// B4: a record env var override still wins over record.effort_level, which
+/// becomes the overridden baseline.
+#[test]
+fn b4_record_env_var_wins_over_canonical_effort_level() {
+ let mut record = test_record();
+ record.effort_level = Some("low".to_string());
+ record
+ .env_vars
+ .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string());
+ let runtime = buzz_agent_runtime();
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+ let effort = surface
+ .normalized
+ .thinking_effort
+ .expect("env var must win over canonical effort");
+ assert_eq!(effort.value.as_deref(), Some("high"));
+ assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
+ assert_eq!(effort.overridden_value.as_deref(), Some("low"));
+}
+
+/// B4: None effort_level does not introduce a spurious tier.
+#[test]
+fn b4_none_canonical_effort_does_not_surface() {
+ let record = test_record(); // effort_level defaults to None
+ let runtime = buzz_agent_runtime();
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+ assert!(
+ surface.normalized.thinking_effort.is_none(),
+ "effort field must be absent when no tier has a value"
+ );
+}
+
+// ── CLAUDE_CONFIG_DIR path resolution (#3493) ─────────────────────────────────
+
+#[test]
+fn claude_mcp_config_path_honors_custom_claude_config_dir() {
+ // #3493: mcp_config_file_path_for_runtime must use the custom dir when
+ // claude_config_dir is Some, not fall back to ~/.claude.json.
+ let record = test_record();
+ let runtime = &KnownAcpRuntime {
+ id: "claude",
+ config_file_path: Some("~/.claude/settings.json"),
+ ..*test_runtime()
+ };
+ let custom_dir = std::path::PathBuf::from("/custom/config/dir");
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(&custom_dir));
+
+ let mcp_path = surface
+ .sources
+ .mcp_config_file_path
+ .expect("mcp_config_file_path must be present for claude runtime");
+ assert_eq!(
+ std::path::Path::new(&mcp_path),
+ custom_dir.join(".claude.json"),
+ "mcp config path must be /.claude.json when CLAUDE_CONFIG_DIR is set"
+ );
+ assert!(
+ surface.claude_config_dir_custom,
+ "claude_config_dir_custom must be true when a custom dir was passed"
+ );
+}
+
+#[test]
+fn claude_config_dir_none_falls_back_to_home_claude_json() {
+ // #3493: None (i.e. the caller stripped an empty string) must resolve to
+ // the default ~/.claude.json path, matching Claude's `CLAUDE_CONFIG_DIR || homedir()`.
+ let record = test_record();
+ let runtime = &KnownAcpRuntime {
+ id: "claude",
+ config_file_path: Some("~/.claude/settings.json"),
+ ..*test_runtime()
+ };
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+ assert!(
+ !surface.claude_config_dir_custom,
+ "claude_config_dir_custom must be false when dir is None (unset)"
+ );
+ assert!(
+ surface
+ .sources
+ .mcp_config_file_path
+ .as_deref()
+ .is_some_and(|p| p.ends_with(".claude.json")),
+ "mcp path must fall back to ~/.claude.json when no custom dir"
+ );
+}
+
+/// F1 regression: the effort control is selected by its `thought_level` category,
+/// and the running value, the write config id, and the picker options all derive
+/// from that single entry — even when the adapter's config id is a nonliteral
+/// value and differs from the canonical (configured) effort.
+///
+/// Live shape: `id="thinking-level", category="thought_level", currentValue="default"`
+/// while canonical `record.effort_level=high`. Both facts must render: configured
+/// `high` as the value and running `default` as the overridden secondary; the
+/// write mechanism must carry the adapter's real id, never a hardcoded `"effort"`.
+#[test]
+fn effort_option_selected_by_category_drives_all_facts() {
+ let mut record = test_record();
+ record.effort_level = Some("high".to_string());
+ let runtime = buzz_agent_rt();
+ let cache = SessionConfigCache {
+ config_options: vec![AcpConfigOptionEntry {
+ config_id: "thinking-level".to_string(),
+ category: Some("thought_level".to_string()),
+ display_name: Some("Thinking level".to_string()),
+ current_value: Some("default".to_string()),
+ options: vec![
+ AcpConfigOptionValue {
+ value: "default".to_string(),
+ display_name: Some("Default".to_string()),
+ },
+ AcpConfigOptionValue {
+ value: "high".to_string(),
+ display_name: Some("High".to_string()),
+ },
+ ],
+ }],
+ available_modes: vec![],
+ available_models: vec![],
+ current_model: None,
+ model_overridden: false,
+ goose_native_config: None,
+ captured_at: "".to_string(),
+ };
+ let tiers = InheritedConfigTiers::default();
+
+ let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None);
+
+ // Two-facts display: configured `high` wins, running `default` is the secondary.
+ let effort = surface
+ .normalized
+ .thinking_effort
+ .expect("effort must surface with both configured and running facts");
+ assert_eq!(effort.value.as_deref(), Some("high"));
+ assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
+ assert_eq!(effort.overridden_value.as_deref(), Some("default"));
+ assert_eq!(
+ effort.overridden_origin,
+ Some(ConfigOrigin::AcpConfigOption)
+ );
+
+ // Write mechanism carries the adapter's real id, never a hardcoded "effort".
+ match &effort.write_via {
+ ConfigWriteMechanism::AcpSetConfigOption { config_id } => {
+ assert_eq!(config_id, "thinking-level");
+ }
+ other => panic!("expected AcpSetConfigOption with adapter id, got {other:?}"),
+ }
+
+ // Picker metadata derives from the same entry.
+ assert_eq!(surface.effort_config_id.as_deref(), Some("thinking-level"));
+ assert_eq!(
+ surface
+ .effort_options
+ .iter()
+ .map(|o| o.value.as_str())
+ .collect::>(),
+ vec!["default", "high"],
+ );
+}
+
+// ── #3493: config_file_path follows a custom CLAUDE_CONFIG_DIR ─────────────────
+
+#[test]
+fn claude_custom_config_dir_reports_isolated_settings_path() {
+ let record = test_record();
+ let runtime = &KnownAcpRuntime {
+ id: "claude",
+ config_file_path: Some("~/.claude/settings.json"),
+ ..*test_runtime()
+ };
+ let custom = std::path::Path::new("/tmp/iso-config");
+
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(custom));
+
+ // The reported settings path is rooted at the custom dir the reader used,
+ // not the static ~/.claude/settings.json metadata. Compare as paths so the
+ // separator is native (Windows joins with `\`, not `/`).
+ assert_eq!(
+ surface
+ .sources
+ .config_file_path
+ .as_deref()
+ .map(std::path::Path::new),
+ Some(custom.join("settings.json").as_path()),
+ );
+ // And the MCP file attribution follows the same custom root.
+ assert_eq!(
+ surface
+ .sources
+ .mcp_config_file_path
+ .as_deref()
+ .map(std::path::Path::new),
+ Some(custom.join(".claude.json").as_path()),
+ );
+}
+
+#[test]
+fn claude_default_config_dir_reports_static_settings_path() {
+ let record = test_record();
+ let runtime = &KnownAcpRuntime {
+ id: "claude",
+ config_file_path: Some("~/.claude/settings.json"),
+ ..*test_runtime()
+ };
+
+ let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None);
+
+ // With no custom dir, the settings path resolves the static tilde metadata.
+ // Compare the trailing components as a path so the check is separator-native.
+ assert!(surface
+ .sources
+ .config_file_path
+ .as_deref()
+ .map(std::path::Path::new)
+ .is_some_and(|p| p.ends_with(".claude/settings.json")));
+ assert!(surface
+ .sources
+ .config_file_path
+ .as_deref()
+ .is_some_and(|p| !p.starts_with('~')));
+}
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs
index 6ca2592538..d96736fb69 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs
@@ -76,8 +76,21 @@ pub enum ConfigOrigin {
}
/// How a config field can be written back to the runtime.
+///
+/// `rename_all_fields` is load-bearing, not decoration: on an internally
+/// tagged enum `rename_all` renames the *variants*, never the variants'
+/// fields, so without it `RespawnWithEnvVar` serializes as
+/// `{"type":"respawnWithEnvVar","env_key":"…"}` while
+/// `desktop/src/shared/api/types.ts` declares `envKey`. `invokeTauri` is an
+/// unchecked cast, so `tsc` cannot see the mismatch — the reader just gets
+/// `undefined`. `wire_format_matches_typescript_contract` below pins the exact
+/// bytes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "camelCase")]
+#[serde(
+ tag = "type",
+ rename_all = "camelCase",
+ rename_all_fields = "camelCase"
+)]
pub enum ConfigWriteMechanism {
/// Update record env vars, save, stop + restart agent.
RespawnWithEnvVar { env_key: String },
@@ -175,6 +188,25 @@ pub struct RuntimeConfigSurface {
pub advanced: Vec,
pub extensions: Vec,
pub sources: ConfigSourceReport,
+ /// #3493: `true` when the panel is reading from a user-set `CLAUDE_CONFIG_DIR`
+ /// rather than the default `~/.claude/`. Used to show the Keychain caveat
+ /// note in the panel: a custom config dir means a fresh Keychain namespace
+ /// (hash-suffixed), so the agent will be logged out unless the user also
+ /// manages `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
+ #[serde(default)]
+ pub claude_config_dir_custom: bool,
+ /// B5: the real `configId` for the `thought_level` ACP config option,
+ /// as advertised by the adapter in `session/new`. Present only for claude
+ /// runtimes after the first session is created. The UI uses this to send
+ /// `set_config_option` without hardcoding the configId.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effort_config_id: Option,
+ /// B5/I-7: the adapter-advertised option values for the `thought_level`
+ /// config option. Present when `effort_config_id` is Some. The UI renders
+ /// these instead of hardcoded low/medium/high so model-specific option sets
+ /// are reflected correctly.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub effort_options: Vec,
}
/// Raw config values extracted from a runtime's config file.
@@ -244,3 +276,105 @@ pub struct AcpModelEntry {
pub name: Option,
pub description: Option,
}
+
+#[cfg(test)]
+mod wire_format_tests {
+ use super::*;
+ use serde_json::json;
+
+ /// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts`
+ /// declares it. Whole-value comparison, not a key-set check: a key-set
+ /// assertion still passes if the variant *name* regresses, and the `type`
+ /// discriminant is what every `switch (writeVia.type)` reads. Compared as
+ /// `serde_json::Value` rather than as text, because JSON object order is
+ /// not semantic and the contract is the keys and values, not the encoder's
+ /// field order.
+ #[test]
+ fn wire_format_matches_typescript_contract() {
+ let cases = [
+ (
+ ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ },
+ json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}),
+ ),
+ (
+ ConfigWriteMechanism::AcpSetConfigOption {
+ config_id: "model".into(),
+ },
+ json!({"type": "acpSetConfigOption", "configId": "model"}),
+ ),
+ (
+ ConfigWriteMechanism::AcpSetSessionModel,
+ json!({"type": "acpSetSessionModel"}),
+ ),
+ (
+ ConfigWriteMechanism::GooseNativeConfigWrite {
+ config_key: "goose.model".into(),
+ },
+ json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}),
+ ),
+ (ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})),
+ ];
+ for (mechanism, expected) in cases {
+ assert_eq!(
+ serde_json::to_value(&mechanism).expect("serialize"),
+ expected
+ );
+ }
+ }
+
+ /// The renderer never sees a bare mechanism — it arrives nested inside
+ /// `NormalizedField`, which is where the mismatch used to hide: the
+ /// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all
+ /// renamed correctly, so only the variant's own field was snake_case.
+ #[test]
+ fn nested_field_is_camel_case_all_the_way_down() {
+ let field = NormalizedField {
+ value: Some("v".into()),
+ origin: ConfigOrigin::EnvVar,
+ write_via: ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ },
+ overridden_value: Some("o".into()),
+ overridden_origin: Some(ConfigOrigin::ConfigFile),
+ is_required: true,
+ };
+ assert_eq!(
+ serde_json::to_value(&field).expect("serialize"),
+ json!({
+ "value": "v",
+ "origin": "envVar",
+ "writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"},
+ "overriddenValue": "o",
+ "overriddenOrigin": "configFile",
+ "isRequired": true,
+ })
+ );
+ }
+
+ /// The contract is singular: the shape the renderer sends back round-trips,
+ /// and the old snake_case spelling is no longer accepted. Without the
+ /// second half, a future revert would still deserialize and the read path
+ /// would look healthy.
+ #[test]
+ fn camel_case_round_trips_and_snake_case_is_rejected() {
+ let parsed: ConfigWriteMechanism =
+ serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#)
+ .expect("the TypeScript shape must deserialize");
+ assert_eq!(
+ parsed,
+ ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ }
+ );
+
+ assert!(
+ serde_json::from_str::(
+ r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"#
+ )
+ .is_err(),
+ "the pre-fix snake_case spelling must not be accepted"
+ );
+ }
+}
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index e53c9114ab..f7e233fbe9 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
@@ -284,13 +284,13 @@ fn record_with(
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
#[test]
fn record_agent_command_own_runtime_wins_over_persona() {
- // A record with its own materialized runtime never consults the
- // persona list — the unified-model resolution.
+ // A record with its own runtime never consults the persona list.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
let record = record_with(Some("claude"), Some("p1"), None);
assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp");
diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
index ee18e554c3..5b048b815c 100644
--- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
@@ -89,6 +89,7 @@ fn record(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
+ effort_level: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs
index b2a56870c7..65cde47f26 100644
--- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs
@@ -349,6 +349,7 @@ fn bare_record() -> ManagedAgentRecord {
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
+ effort_level: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index 16234aa3d6..272c03348b 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -9,6 +9,7 @@ pub(crate) use agent_env::{
baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,
};
mod backend;
+pub(crate) mod claude_config;
pub(crate) mod config_bridge;
pub(crate) mod custom_harnesses;
mod definition_validation;
diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs
index a57676f0a9..72cf466427 100644
--- a/desktop/src-tauri/src/managed_agents/nest.rs
+++ b/desktop/src-tauri/src/managed_agents/nest.rs
@@ -11,10 +11,12 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec
#[cfg(test)]
use super::{BackendKind, RespondTo};
use crate::app_state::AppState;
-use crate::relay::relay_ws_url_with_override;
+use crate::commands::{capture_relay_target, fetch_archived_pubkeys_at};
+use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
+use std::sync::Mutex;
use tauri::{AppHandle, Manager};
use crate::managed_agents::discovery::known_skill_dirs;
@@ -523,19 +525,35 @@ fn escape_md_cell(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
+/// True iff the relay has archived this instance's identity. Membership is
+/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty
+/// set (relay unreachable) fails open — see [`regenerate_nest_context`].
+fn is_archived(record: &ManagedAgentRecord, archived: &HashSet) -> bool {
+ archived.contains(&record.pubkey.to_ascii_lowercase())
+}
+
pub fn render_dynamic_section(
personas: &[AgentDefinition],
agents: &[ManagedAgentRecord],
+ archived: &HashSet,
relay_url: &str,
) -> String {
- let active_agents = if agents.is_empty() {
+ // Every managed agent is eligible on every community — `relay_url` is a
+ // legacy creation-era field that `effective_agent_relay_url()` deliberately
+ // ignores, and snapshot-imported records store it empty by design. The only
+ // roster filter is identity-archive.
+ let live: Vec<&ManagedAgentRecord> = agents
+ .iter()
+ .filter(|a| !is_archived(a, archived))
+ .collect();
+ let active_agents = if live.is_empty() {
"## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*"
.to_string()
} else {
let mut table =
"## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|"
.to_string();
- for agent in agents {
+ for agent in live {
let role = agent
.persona_id
.as_deref()
@@ -645,7 +663,124 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io
Ok(())
}
-pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
+/// Serializes nest-context writes so a slow, stale regeneration cannot roll the
+/// file back over a newer one. This is an ordered, latest-request-wins gate —
+/// not a work coalescer: every superseded generation still performs its relay
+/// reads, then drops its result at commit time. Adding a true dirty-loop owner
+/// would be a larger change and is unwarranted at this user-driven trigger rate.
+///
+/// Each regeneration request claims a monotonic generation *synchronously* at
+/// request time (see [`NestRegenGate::claim`]), so the generation encodes
+/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's
+/// regen before the next edit's. The claimed generation travels with the
+/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops
+/// its result once a *newer generation has been requested*, even if that newer
+/// generation later fails before it writes. Gating on the highest *requested*
+/// generation — not the highest *written* one — is what stops a slow, stale
+/// pre-edit render from publishing after a newer post-edit render was claimed
+/// and then failed during its relay work (which would otherwise leave the
+/// obsolete roster authoritative until the next unrelated trigger). Declared
+/// semantic: once a newer regeneration is requested, no older one publishes;
+/// if that newer one fails, the file simply waits for the next trigger.
+///
+/// `claim` and `commit` share one lock, so the "is this still the newest
+/// request?" compare is atomic with the synchronous file write. A bare atomic
+/// watermark checked separately from the write would let a new claim slip
+/// between an older task's eligibility check and its write; holding the lock
+/// across both closes that window (no `await` occurs while it is held).
+struct NestRegenGate {
+ /// Highest generation *requested* so far (`0` = none yet). Advanced by
+ /// [`claim`] and read by [`commit`]; guarding both under this single lock
+ /// keeps the eligibility compare atomic with the file write.
+ highest_requested: Mutex,
+}
+
+impl NestRegenGate {
+ const fn new() -> Self {
+ Self {
+ highest_requested: Mutex::new(0),
+ }
+ }
+
+ /// Claim the next generation. Call synchronously at request time so the
+ /// value reflects when the regeneration was requested, not when its task
+ /// happens to run. Advancing the shared watermark here is what lets a later
+ /// [`commit`] recognize — and drop — any older generation's stale render.
+ fn claim(&self) -> u64 {
+ let mut requested = self
+ .highest_requested
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ *requested += 1;
+ *requested
+ }
+
+ /// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns
+ /// `Some(generation)` if it acquired the lock — i.e. a claim could proceed
+ /// with no contention — or `None` if the lock is already held, meaning a
+ /// concurrent claim would block on it. Because `claim` and `commit` share
+ /// `highest_requested`, calling this from inside `commit_hooked`'s
+ /// under-lock hook reports `None`: the eligibility compare and the write
+ /// are serialized against any new claim. A design that advanced the
+ /// watermark under a separate lock (or a lock-free atomic) would report
+ /// `Some` here — the regression this probe proves absent, with no reliance
+ /// on elapsed time or thread scheduling.
+ #[cfg(test)]
+ fn try_claim(&self) -> Option {
+ match self.highest_requested.try_lock() {
+ Ok(mut requested) => {
+ *requested += 1;
+ Some(*requested)
+ }
+ Err(std::sync::TryLockError::WouldBlock) => None,
+ Err(std::sync::TryLockError::Poisoned(poisoned)) => {
+ let mut requested = poisoned.into_inner();
+ *requested += 1;
+ Some(*requested)
+ }
+ }
+ }
+
+ /// Commit `content` for `generation`, dropping the write once a newer
+ /// generation has been *requested* (regardless of whether that newer
+ /// generation has written or ever will). Returns whether the file was
+ /// written. The lock spans the compare and the write so the check-and-write
+ /// is atomic and no await occurs while it is held.
+ fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result {
+ self.commit_hooked(agents_md, content, generation, || {})
+ }
+
+ /// [`commit`] with a hook invoked while the lock is held, after the
+ /// eligibility compare and before the write. Production passes a no-op, so
+ /// this is exactly [`commit`]; tests pass a hook that calls [`try_claim`]
+ /// to prove no claim can land inside the compare-then-write window — the
+ /// probe reports the lock held here, whereas the flawed
+ /// separate-watermark/separate-write-lock design would report it free. The
+ /// `impl FnOnce` monomorphizes the no-op away.
+ fn commit_hooked(
+ &self,
+ agents_md: &Path,
+ content: &str,
+ generation: u64,
+ under_lock: impl FnOnce(),
+ ) -> io::Result {
+ let requested = self
+ .highest_requested
+ .lock()
+ .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?;
+ if generation < *requested {
+ return Ok(false);
+ }
+ under_lock();
+ upsert_managed_section(agents_md, content)?;
+ Ok(true)
+ }
+}
+
+/// Process-wide ordered write gate for nest-context regeneration.
+static NEST_REGEN: NestRegenGate = NestRegenGate::new();
+
+pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> {
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
let agents_md = nest.join("AGENTS.md");
@@ -656,23 +791,51 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
let personas = load_personas(app)?;
let agents = load_managed_agents(app)?;
let state = app.state::();
- let relay_url = relay_ws_url_with_override(&state);
- let content = render_dynamic_section(&personas, &agents, &relay_url);
- upsert_managed_section(&agents_md, &content)
+ // Capture the relay target once, before any network work, so this
+ // generation's rendered footer, NIP-11 signer, and snapshot query all
+ // belong to one relay even if a workspace switch changes the override
+ // between the two archive awaits below.
+ let target = capture_relay_target(&state);
+ // Identity-archived agents live only in the relay's `kind:13535` snapshot;
+ // local records all read `is_active: true`. Fails open (empty set → render
+ // everyone) so an unreachable relay can't blank the roster. The archive read
+ // uses the same captured target as the rendered relay; a later generation's
+ // task always wins the commit, so a fallback-relay boot render cannot bury a
+ // later apply_workspace render.
+ let archived: HashSet = fetch_archived_pubkeys_at(&state, &target)
+ .await
+ .into_iter()
+ .collect();
+ let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url);
+ NEST_REGEN
+ .commit(&agents_md, &content, generation)
.map_err(|e| format!("regenerate nest context: {e}"))?;
Ok(())
}
-/// Convenience wrapper: regenerates nest context, logging a warning on failure.
+/// Convenience wrapper: claims a regeneration generation, then regenerates on a
+/// spawned task, logging a warning on failure.
///
/// All call sites treat regeneration as fire-and-forget — agents run fine with
/// a stale AGENTS.md, so we warn and continue rather than propagating the error.
+/// The generation is claimed *here*, synchronously, so it encodes call order;
+/// the spawned task carries it into [`NestRegenGate::commit`], which drops
+/// a stale render rather than letting a slow task overwrite a newer file.
+/// Archive/unarchive trigger this directly, but the regen races the relay's
+/// `kind:13535` snapshot update, so a just-archived agent may still linger for
+/// one cycle until the next regen (any agent/team edit or the next launch).
pub fn try_regenerate_nest(app: &AppHandle) {
- if let Err(error) = regenerate_nest_context(app) {
- eprintln!("buzz-desktop: nest context regeneration failed: {error}");
- }
+ let generation = NEST_REGEN.claim();
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ if let Err(error) = regenerate_nest_context(&app, generation).await {
+ eprintln!("buzz-desktop: nest context regeneration failed: {error}");
+ }
+ });
}
+#[cfg(test)]
+mod render_tests;
#[cfg(test)]
mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs
new file mode 100644
index 0000000000..ed4ee2c1f9
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs
@@ -0,0 +1,713 @@
+//! Tests for the dynamic AGENTS.md section renderer, the managed-section
+//! upsert, and the regeneration gate. Split from `tests.rs` to keep
+//! each test file under the repository's per-file line ratchet.
+
+use super::*;
+use std::collections::HashSet;
+
+/// Relay URL passed to render calls. Since the roster no longer filters on
+/// `relay_url`, this is only echoed into the Workspace footer.
+const TEST_RELAY: &str = "ws://example.com:3000";
+
+fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
+ AgentDefinition {
+ id: id.to_string(),
+ display_name: display_name.to_string(),
+ avatar_url: None,
+ system_prompt: String::new(),
+ runtime: None,
+ model: None,
+ provider: None,
+ name_pool: vec![],
+ is_builtin: false,
+ is_active: true,
+ shared: false,
+ source_team: None,
+ source_team_persona_slug: None,
+ catalog_source: None,
+ env_vars: std::collections::BTreeMap::new(),
+ respond_to: None,
+ respond_to_allowlist: Vec::new(),
+ parallelism: None,
+ created_at: String::new(),
+ updated_at: String::new(),
+ }
+}
+
+fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
+ ManagedAgentRecord {
+ pubkey: String::new(),
+ name: name.to_string(),
+ persona_id: persona_id.map(|s| s.to_string()),
+ private_key_nsec: String::new(),
+ auth_tag: None,
+ relay_url: TEST_RELAY.to_string(),
+ avatar_url: None,
+ acp_command: String::new(),
+ agent_command: String::new(),
+ agent_command_override: None,
+ agent_args: vec![],
+ mcp_command: String::new(),
+ turn_timeout_seconds: 0,
+ idle_timeout_seconds: None,
+ max_turn_duration_seconds: None,
+ parallelism: 1,
+ system_prompt: None,
+ model: None,
+ provider: None,
+ persona_source_version: None,
+ start_on_app_launch: false,
+ auto_restart_on_config_change: true,
+ runtime_pid: None,
+ backend: BackendKind::default(),
+ backend_agent_id: None,
+ provider_policy_pending: false,
+ provider_binary_path: None,
+ team_id: None,
+ persona_team_dir: None,
+ persona_name_in_team: None,
+ created_at: String::new(),
+ updated_at: String::new(),
+ last_started_at: None,
+ last_stopped_at: None,
+ last_exit_code: None,
+ last_error: None,
+ last_error_code: None,
+ respond_to: RespondTo::default(),
+ respond_to_allowlist: vec![],
+ env_vars: std::collections::BTreeMap::new(),
+ display_name: None,
+ slug: None,
+ runtime: None,
+ name_pool: Vec::new(),
+ is_builtin: false,
+ is_active: true,
+ shared: false,
+ source_team: None,
+ source_team_persona_slug: None,
+ catalog_source: None,
+ definition_respond_to: None,
+ definition_respond_to_allowlist: Vec::new(),
+ definition_parallelism: None,
+ relay_mesh: None,
+ effort_level: None,
+ }
+}
+
+#[test]
+fn test_render_dynamic_section_with_agents() {
+ let personas = vec![make_persona("p1", "Builder")];
+ let agents = vec![make_agent("Kit", Some("p1"))];
+ let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
+ assert!(output.contains("| Kit | Builder | @Kit |"));
+ assert!(output.contains("| Name | Persona | How to address |"));
+ assert!(output.contains("## Workspace"));
+}
+
+#[test]
+fn test_render_dynamic_section_empty() {
+ let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY);
+ assert!(output.contains("No agents deployed yet"));
+}
+
+#[test]
+fn test_render_dynamic_section_agent_no_persona() {
+ let personas = vec![make_persona("p1", "Builder")];
+ let agents = vec![make_agent("Scout", Some("nonexistent"))];
+ let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
+ assert!(output.contains("| Scout | — | @Scout |"));
+}
+
+#[test]
+fn test_render_excludes_archived_agents() {
+ let personas = vec![make_persona("p1", "Builder")];
+ let mut live = make_agent("Live", Some("p1"));
+ live.pubkey = "aa".repeat(32);
+ let mut gone = make_agent("Archived", Some("p1"));
+ gone.pubkey = "bb".repeat(32);
+ let archived: HashSet = [gone.pubkey.clone()].into_iter().collect();
+
+ let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY);
+
+ assert!(output.contains("| Live | Builder | @Live |"));
+ assert!(
+ !output.contains("Archived"),
+ "archived agent must not render"
+ );
+}
+
+#[test]
+fn test_render_archived_match_is_case_insensitive() {
+ let personas = vec![make_persona("p1", "Builder")];
+ let mut gone = make_agent("Archived", Some("p1"));
+ gone.pubkey = "AB".repeat(32); // uppercase hex in the record
+ // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`.
+ let archived: HashSet = ["ab".repeat(32)].into_iter().collect();
+
+ let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY);
+
+ assert!(
+ output.contains("No agents deployed yet"),
+ "all-archived roster renders the empty placeholder"
+ );
+}
+
+#[test]
+fn test_render_empty_archived_set_renders_all() {
+ let personas = vec![make_persona("p1", "Builder")];
+ let mut a = make_agent("Kit", Some("p1"));
+ a.pubkey = "cc".repeat(32);
+ // Fail-open: an empty snapshot (relay unreachable) must render everyone.
+ let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY);
+ assert!(output.contains("| Kit | Builder | @Kit |"));
+}
+
+#[test]
+fn test_render_keeps_agent_with_legacy_foreign_relay_pin() {
+ // `relay_url` is a legacy creation-era field that `effective_agent_relay_url()`
+ // deliberately ignores — every agent is eligible on every community. A record
+ // whose stored pin points at a now-defunct relay must still render on the
+ // active workspace; only identity-archive removes an agent.
+ let personas = vec![make_persona("p1", "Builder")];
+ let here = make_agent("Local", Some("p1"));
+ let mut elsewhere = make_agent("Foreign", Some("p1"));
+ elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string();
+
+ let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY);
+
+ assert!(output.contains("| Local | Builder | @Local |"));
+ assert!(
+ output.contains("| Foreign | Builder | @Foreign |"),
+ "a legacy foreign relay pin must not hide an agent — the pin is ignored"
+ );
+}
+
+#[test]
+fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() {
+ // Snapshot-imported records store `relay_url: ""` by design; they resolve
+ // to the workspace relay at runtime. Such an agent must appear on the active
+ // workspace, not be hidden by an empty pin.
+ let personas = vec![make_persona("p1", "Builder")];
+ let mut imported = make_agent("Imported", Some("p1"));
+ imported.relay_url = String::new();
+
+ let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY);
+
+ assert!(
+ output.contains("| Imported | Builder | @Imported |"),
+ "an empty relay_url (snapshot-import shape) must still render"
+ );
+}
+
+#[test]
+fn test_upsert_managed_section_with_markers() {
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("AGENTS.md");
+ fs::write(
+ &file,
+ "# Header\n\nsome content\n\n\nold section\n\n\nafter\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "new section").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+ assert!(result.contains(""));
+ assert!(result.contains("new section"));
+ assert!(!result.contains("old section"));
+ assert!(result.contains("# Header"));
+ assert!(result.contains("some content"));
+ assert!(result.contains("after"));
+}
+
+#[test]
+fn test_upsert_managed_section_without_markers() {
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("AGENTS.md");
+ fs::write(&file, "# Header\n\nexisting content\n").unwrap();
+
+ upsert_managed_section(&file, "injected section").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+ assert!(result.contains("# Header"));
+ assert!(result.contains("existing content"));
+ assert!(result.contains(""));
+ assert!(result.contains("injected section"));
+ let begin_pos = result.find("\nsome middle content\n\nold section\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "new section").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+
+ assert!(result.contains("# Header"), "original header must survive");
+ assert!(
+ result.contains("new section"),
+ "new content must be present"
+ );
+ assert!(
+ result.contains("some middle content"),
+ "content between markers must survive"
+ );
+
+ // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
+ assert_eq!(
+ result.matches(BEGIN_MARKER).count(),
+ 1,
+ "exactly one BEGIN marker after orphan cleanup"
+ );
+
+ // The single BEGIN marker must have a matching END marker after it.
+ let begin_pos = result
+ .find(BEGIN_MARKER)
+ .expect("BEGIN marker must be present");
+ let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
+ assert!(
+ end_pos.is_some(),
+ "an END marker must appear after the appended BEGIN marker"
+ );
+}
+
+#[test]
+fn test_upsert_begin_only_no_end() {
+ // A file with BEGIN but no END has an orphan marker.
+ // find_managed_markers returns None (no END found after BEGIN),
+ // so strip_orphan_begin_marker removes the BEGIN line.
+ // Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
+ // not the body that came after it).
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("AGENTS.md");
+ fs::write(
+ &file,
+ "# Header\n\nsome content\n\n\norphaned section without end marker\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "fresh section").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+
+ assert!(result.contains("# Header"), "original header must survive");
+ assert!(
+ result.contains("some content"),
+ "original body must survive"
+ );
+ assert!(
+ result.contains("fresh section"),
+ "new content must be present"
+ );
+
+ let begin_pos = result
+ .find(BEGIN_MARKER)
+ .expect("BEGIN marker must be present");
+ let end_pos = result.find(END_MARKER).expect("END marker must be present");
+ assert!(
+ begin_pos < end_pos,
+ "the appended BEGIN marker must precede the appended END marker"
+ );
+
+ // Exactly one BEGIN marker after orphan cleanup.
+ assert_eq!(
+ result.matches(BEGIN_MARKER).count(),
+ 1,
+ "exactly one BEGIN marker after orphan cleanup"
+ );
+}
+
+#[test]
+fn test_upsert_duplicate_markers() {
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("AGENTS.md");
+ fs::write(
+ &file,
+ "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "replaced").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+
+ assert!(
+ result.contains("replaced"),
+ "replacement content must be present"
+ );
+ assert!(
+ !result.contains("first block"),
+ "first block must be replaced"
+ );
+ assert!(
+ result.contains("second block"),
+ "second pair content must survive"
+ );
+ assert!(
+ result.contains("between blocks"),
+ "text between pairs must survive"
+ );
+}
+
+#[test]
+fn test_upsert_marker_in_code_block() {
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("AGENTS.md");
+ // Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
+ fs::write(
+ &file,
+ "# Header\n\n \n\nReal content here\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "appended content").unwrap();
+
+ let result = fs::read_to_string(&file).unwrap();
+
+ assert!(
+ result.contains(" "),
+ "indented marker inside code block must be preserved verbatim"
+ );
+ assert!(
+ result.contains("appended content"),
+ "new content must be appended"
+ );
+ assert!(
+ result.contains("Real content here"),
+ "existing body must survive"
+ );
+
+ // The real markers appended at the end must be at line-start (column 0).
+ let begin_pos = result
+ .find("\nexisting section\n\n",
+ )
+ .unwrap();
+
+ upsert_managed_section(&file, "same content").unwrap();
+ let after_first = fs::read_to_string(&file).unwrap();
+
+ upsert_managed_section(&file, "same content").unwrap();
+ let after_second = fs::read_to_string(&file).unwrap();
+
+ assert_eq!(
+ after_first, after_second,
+ "upsert must be idempotent: second call must not alter the file"
+ );
+}
+
+/// Write an AGENTS.md skeleton with an empty managed section and return its path.
+fn agents_md_with_markers(dir: &Path) -> PathBuf {
+ let file = dir.join("AGENTS.md");
+ fs::write(
+ &file,
+ "# Header\n\n\n\n\n",
+ )
+ .unwrap();
+ file
+}
+
+#[test]
+fn commit_newer_generation_wins_over_a_stale_finisher() {
+ // Models the CRUD race: generation A snapshots pre-edit state and its relay
+ // fetch is slow; generation B snapshots post-edit state and commits first.
+ // When A finally finishes and commits LAST, its lower generation is dropped
+ // so the file still reflects B. Ordering of *finishing* is the only variable —
+ // the generation, claimed at request time, decides the winner.
+ let gate = NestRegenGate::new();
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+
+ let gen_a = gate.claim(); // pre-edit request
+ let gen_b = gate.claim(); // post-edit request
+ assert!(gen_a < gen_b);
+
+ // B (newer) commits first.
+ assert!(gate.commit(&file, "post-edit roster", gen_b).unwrap());
+ // A (older) finishes last and must be dropped.
+ assert!(
+ !gate.commit(&file, "pre-edit roster", gen_a).unwrap(),
+ "a stale (lower-generation) render must not overwrite a newer one"
+ );
+
+ let content = fs::read_to_string(&file).unwrap();
+ assert!(content.contains("post-edit roster"));
+ assert!(
+ !content.contains("pre-edit roster"),
+ "final file must reflect the newer generation, not the stale finisher"
+ );
+}
+
+#[test]
+fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() {
+ // Models boot→apply_workspace relay switching: the boot regen (generation 1,
+ // fallback relay) is claimed first but finishes last; the apply_workspace
+ // regen (generation 2, workspace relay) commits first. The workspace relay
+ // render must survive even though the fallback-relay task writes afterward.
+ let gate = NestRegenGate::new();
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+
+ let boot_gen = gate.claim(); // boot, fallback relay
+ let apply_gen = gate.claim(); // apply_workspace, workspace relay
+
+ // apply_workspace's render lands first.
+ assert!(gate
+ .commit(
+ &file,
+ "## Workspace\n- Relay: wss://workspace.example",
+ apply_gen,
+ )
+ .unwrap());
+ // Boot's slower fallback-relay render finishes last and is dropped.
+ assert!(!gate
+ .commit(
+ &file,
+ "## Workspace\n- Relay: wss://fallback.example",
+ boot_gen,
+ )
+ .unwrap());
+
+ let content = fs::read_to_string(&file).unwrap();
+ assert!(content.contains("wss://workspace.example"));
+ assert!(
+ !content.contains("wss://fallback.example"),
+ "the fallback-relay boot render must not overwrite the workspace-relay render"
+ );
+}
+
+#[test]
+fn commit_failed_newer_request_still_supersedes_older_snapshot() {
+ // Carl 4954831197, case 1: a newer request that never writes must still
+ // permanently supersede an older snapshot. gen1 (pre-edit) is claimed and
+ // its relay work is slow; an edit claims gen2 (post-edit); gen2 then FAILS
+ // during its relay work, so it never commits. When gen1 finally finishes,
+ // it must NOT publish its obsolete roster — gating on highest-*requested*
+ // (advanced by gen2's claim) drops it, whereas gating on highest-*written*
+ // (0, since gen2 never wrote) would wrongly let gen1 publish.
+ let gate = NestRegenGate::new();
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+
+ let gen1 = gate.claim(); // pre-edit request
+ let gen2 = gate.claim(); // post-edit request
+ assert!(gen1 < gen2);
+
+ // gen2 fails during relay work and never reaches commit — nothing written.
+
+ // gen1 finishes last; its stale render must be dropped.
+ assert!(
+ !gate.commit(&file, "pre-edit roster", gen1).unwrap(),
+ "an older snapshot must not publish once a newer generation was requested, \
+ even if that newer generation failed before writing"
+ );
+
+ let content = fs::read_to_string(&file).unwrap();
+ assert!(
+ !content.contains("pre-edit roster"),
+ "the obsolete pre-edit roster must never become authoritative"
+ );
+}
+
+#[test]
+fn commit_claim_at_the_older_tasks_cutover_supersedes_it() {
+ // Carl 4954831197, case 2: a claim arriving at the older task's commit
+ // cutover must not slip between the eligibility compare and the write.
+ // gen1 becomes eligible and enters `commit`; while it holds the lock
+ // (after the compare, before the write) a claim is attempted. The correct
+ // single-lock gate shares `highest_requested` between `claim` and
+ // `commit`, so that claim cannot acquire the lock until gen1's write
+ // releases it — the flawed separate-watermark/separate-write-lock design
+ // Carl warned about would let the claim proceed immediately.
+ //
+ // Determinism: the under-lock hook calls `try_claim`, a non-blocking claim
+ // against the exact lock `claim` takes, and asserts it reports the lock
+ // held (`None`). This is a direct statement about the gate's locking with
+ // no thread, channel, or sleep — the correct design necessarily returns
+ // `None` and the separate-watermark design necessarily returns `Some`, so
+ // the discriminator cannot be flipped by scheduler timing.
+ let gate = NestRegenGate::new();
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+
+ let gen1 = gate.claim();
+
+ let wrote_gen1 = gate
+ .commit_hooked(&file, "gen1 roster", gen1, || {
+ // We are past the eligibility compare and hold the lock. A claim
+ // attempted now must find the shared lock held — proving the
+ // compare and the write are atomic against any new claim.
+ assert!(
+ gate.try_claim().is_none(),
+ "a claim must not acquire the gate while an older commit holds \
+ the shared lock between its eligibility check and its write — \
+ the eligibility compare is not atomic with the write \
+ (separate-watermark design)"
+ );
+ })
+ .unwrap();
+ assert!(
+ wrote_gen1,
+ "gen1 was still the highest request when it entered commit, so its write \
+ is legitimate; the newer request only lands after the lock releases"
+ );
+
+ // The lock is free once commit returns, so a newer request now claims and
+ // may publish over gen1.
+ let gen2 = gate.claim();
+ assert!(gen1 < gen2);
+ assert!(gate.commit(&file, "gen2 roster", gen2).unwrap());
+
+ let content = fs::read_to_string(&file).unwrap();
+ assert!(content.contains("gen2 roster"));
+ assert!(!content.contains("gen1 roster"));
+}
+
+#[test]
+fn commit_equal_generation_is_allowed() {
+ // The gate rejects only strictly-lower generations. Re-committing the same
+ // generation (e.g. a retried request) is permitted and refreshes the file.
+ let gate = NestRegenGate::new();
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+
+ let gen = gate.claim();
+ assert!(gate.commit(&file, "first", gen).unwrap());
+ assert!(
+ gate.commit(&file, "second", gen).unwrap(),
+ "an equal generation must still be allowed to write"
+ );
+
+ let content = fs::read_to_string(&file).unwrap();
+ assert!(content.contains("second"));
+}
+
+#[test]
+fn commit_poisoned_lock_returns_error_instead_of_panicking() {
+ // A poisoned gate lock must degrade to an io::Error so the fire-and-forget
+ // caller warns and continues, never panicking the desktop process (root
+ // AGENTS.md: no new expect() in production paths). Poison the lock by
+ // panicking a thread while it holds the guard, then assert commit yields
+ // Err rather than unwinding.
+ let gate = std::sync::Arc::new(NestRegenGate::new());
+ let tmp = tempfile::tempdir().unwrap();
+ let file = agents_md_with_markers(tmp.path());
+ let gen = gate.claim();
+
+ let poisoner = gate.clone();
+ let _ = std::thread::spawn(move || {
+ let _guard = poisoner.highest_requested.lock().unwrap();
+ panic!("poison the gate lock");
+ })
+ .join();
+
+ let result = gate.commit(&file, "after poison", gen);
+ assert!(
+ result.is_err(),
+ "a poisoned lock must surface as an error, not a panic"
+ );
+}
diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs
index 67cdb5fbaf..bc67a5b69e 100644
--- a/desktop/src-tauri/src/managed_agents/nest/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs
@@ -422,415 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() {
);
}
-fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
- AgentDefinition {
- id: id.to_string(),
- display_name: display_name.to_string(),
- avatar_url: None,
- system_prompt: String::new(),
- runtime: None,
- model: None,
- provider: None,
- name_pool: vec![],
- is_builtin: false,
- is_active: true,
- shared: false,
- source_team: None,
- source_team_persona_slug: None,
- catalog_source: None,
- env_vars: std::collections::BTreeMap::new(),
- respond_to: None,
- respond_to_allowlist: Vec::new(),
- parallelism: None,
- created_at: String::new(),
- updated_at: String::new(),
- }
-}
-
-fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
- ManagedAgentRecord {
- pubkey: String::new(),
- name: name.to_string(),
- persona_id: persona_id.map(|s| s.to_string()),
- private_key_nsec: String::new(),
- auth_tag: None,
- relay_url: String::new(),
- avatar_url: None,
- acp_command: String::new(),
- agent_command: String::new(),
- agent_command_override: None,
- agent_args: vec![],
- mcp_command: String::new(),
- turn_timeout_seconds: 0,
- idle_timeout_seconds: None,
- max_turn_duration_seconds: None,
- parallelism: 1,
- system_prompt: None,
- model: None,
- provider: None,
- persona_source_version: None,
- start_on_app_launch: false,
- auto_restart_on_config_change: true,
- runtime_pid: None,
- backend: BackendKind::default(),
- backend_agent_id: None,
- provider_policy_pending: false,
- provider_binary_path: None,
- team_id: None,
- persona_team_dir: None,
- persona_name_in_team: None,
- created_at: String::new(),
- updated_at: String::new(),
- last_started_at: None,
- last_stopped_at: None,
- last_exit_code: None,
- last_error: None,
- last_error_code: None,
- respond_to: RespondTo::default(),
- respond_to_allowlist: vec![],
- env_vars: std::collections::BTreeMap::new(),
- display_name: None,
- slug: None,
- runtime: None,
- name_pool: Vec::new(),
- is_builtin: false,
- is_active: true,
- shared: false,
- source_team: None,
- source_team_persona_slug: None,
- catalog_source: None,
- definition_respond_to: None,
- definition_respond_to_allowlist: Vec::new(),
- definition_parallelism: None,
- relay_mesh: None,
- }
-}
-
-#[test]
-fn test_render_dynamic_section_with_agents() {
- let personas = vec![make_persona("p1", "Builder")];
- let agents = vec![make_agent("Kit", Some("p1"))];
- let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
- assert!(output.contains("| Kit | Builder | @Kit |"));
- assert!(output.contains("| Name | Persona | How to address |"));
- assert!(output.contains("## Workspace"));
-}
-
-#[test]
-fn test_render_dynamic_section_empty() {
- let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
- assert!(output.contains("No agents deployed yet"));
-}
-
-#[test]
-fn test_render_dynamic_section_agent_no_persona() {
- let personas = vec![make_persona("p1", "Builder")];
- let agents = vec![make_agent("Scout", Some("nonexistent"))];
- let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
- assert!(output.contains("| Scout | — | @Scout |"));
-}
-
-#[test]
-fn test_upsert_managed_section_with_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\nsome content\n\n\nold section\n\n\nafter\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "new section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
- assert!(result.contains(""));
- assert!(result.contains("new section"));
- assert!(!result.contains("old section"));
- assert!(result.contains("# Header"));
- assert!(result.contains("some content"));
- assert!(result.contains("after"));
-}
-
-#[test]
-fn test_upsert_managed_section_without_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(&file, "# Header\n\nexisting content\n").unwrap();
-
- upsert_managed_section(&file, "injected section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
- assert!(result.contains("# Header"));
- assert!(result.contains("existing content"));
- assert!(result.contains(""));
- assert!(result.contains("injected section"));
- let begin_pos = result.find("\nsome middle content\n\nold section\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "new section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(result.contains("# Header"), "original header must survive");
- assert!(
- result.contains("new section"),
- "new content must be present"
- );
- assert!(
- result.contains("some middle content"),
- "content between markers must survive"
- );
-
- // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
- assert_eq!(
- result.matches(BEGIN_MARKER).count(),
- 1,
- "exactly one BEGIN marker after orphan cleanup"
- );
-
- // The single BEGIN marker must have a matching END marker after it.
- let begin_pos = result
- .find(BEGIN_MARKER)
- .expect("BEGIN marker must be present");
- let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
- assert!(
- end_pos.is_some(),
- "an END marker must appear after the appended BEGIN marker"
- );
-}
-
-#[test]
-fn test_upsert_begin_only_no_end() {
- // A file with BEGIN but no END has an orphan marker.
- // find_managed_markers returns None (no END found after BEGIN),
- // so strip_orphan_begin_marker removes the BEGIN line.
- // Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
- // not the body that came after it).
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\nsome content\n\n\norphaned section without end marker\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "fresh section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(result.contains("# Header"), "original header must survive");
- assert!(
- result.contains("some content"),
- "original body must survive"
- );
- assert!(
- result.contains("fresh section"),
- "new content must be present"
- );
-
- let begin_pos = result
- .find(BEGIN_MARKER)
- .expect("BEGIN marker must be present");
- let end_pos = result.find(END_MARKER).expect("END marker must be present");
- assert!(
- begin_pos < end_pos,
- "the appended BEGIN marker must precede the appended END marker"
- );
-
- // Exactly one BEGIN marker after orphan cleanup.
- assert_eq!(
- result.matches(BEGIN_MARKER).count(),
- 1,
- "exactly one BEGIN marker after orphan cleanup"
- );
-}
-
-#[test]
-fn test_upsert_duplicate_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "replaced").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(
- result.contains("replaced"),
- "replacement content must be present"
- );
- assert!(
- !result.contains("first block"),
- "first block must be replaced"
- );
- assert!(
- result.contains("second block"),
- "second pair content must survive"
- );
- assert!(
- result.contains("between blocks"),
- "text between pairs must survive"
- );
-}
-
-#[test]
-fn test_upsert_marker_in_code_block() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- // Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
- fs::write(
- &file,
- "# Header\n\n \n\nReal content here\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "appended content").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(
- result.contains(" "),
- "indented marker inside code block must be preserved verbatim"
- );
- assert!(
- result.contains("appended content"),
- "new content must be appended"
- );
- assert!(
- result.contains("Real content here"),
- "existing body must survive"
- );
-
- // The real markers appended at the end must be at line-start (column 0).
- let begin_pos = result
- .find("\nexisting section\n\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "same content").unwrap();
- let after_first = fs::read_to_string(&file).unwrap();
-
- upsert_managed_section(&file, "same content").unwrap();
- let after_second = fs::read_to_string(&file).unwrap();
-
- assert_eq!(
- after_first, after_second,
- "upsert must be idempotent: second call must not alter the file"
- );
-}
-
#[test]
fn refresh_agents_md_writes_version_file() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs
index a6a50540bb..734772d73d 100644
--- a/desktop/src-tauri/src/managed_agents/parallelism.rs
+++ b/desktop/src-tauri/src/managed_agents/parallelism.rs
@@ -118,6 +118,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
index 682fbef62f..af8cfe6618 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
@@ -59,6 +59,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs
index c005510907..f7f5d5c5d0 100644
--- a/desktop/src-tauri/src/managed_agents/readiness.rs
+++ b/desktop/src-tauri/src/managed_agents/readiness.rs
@@ -1465,9 +1465,8 @@ mod tests {
#[test]
fn resolve_effective_agent_env_user_env_wins_over_structured_fields() {
- // A record whose env_vars explicitly set provider/model must win over
- // any baked defaults. In OSS test builds the baked map is empty, so
- // this test validates the user-env layer is present in the output.
+ // User env_vars must win over baked defaults; in OSS builds baked map is empty,
+ // so this validates the user-env layer is present in the output.
let mut env_vars = BTreeMap::new();
env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string());
env_vars.insert(
@@ -1531,6 +1530,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
};
let runtime = known_acp_runtime_exact("buzz-agent");
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 8b57b161b7..923530d34b 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -14,6 +14,7 @@ use crate::{
util::now_iso,
};
+use super::claude_config::{apply_claude_model_env, apply_effort_env};
mod path;
pub(in crate::managed_agents) use path::build_augmented_path;
pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited};
@@ -775,17 +776,8 @@ pub fn spawn_agent_child(
command.env("BUZZ_ACP_RELAY_OBSERVER", "true");
- // ── Git credential helper for Buzz relay ──────────────────────────
- //
- // Agents need to clone/push repos hosted on the Buzz relay's git
- // server, which authenticates via NIP-98. The `git-credential-nostr`
- // binary signs auth events using the agent's nostr key.
- //
- // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no
- // filesystem writes) scoped to the relay's git URL so we don't
- // interfere with other remotes (e.g. GitHub).
- //
- // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync.
+ // Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr.
+ // Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY.
if let Some(cred_helper) = resolve_command("git-credential-nostr") {
let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url);
@@ -810,17 +802,27 @@ pub fn spawn_agent_child(
);
}
- // ── User env vars: definition floor + global + live persona + agent overrides ──
- //
- // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`:
- // baked floor → runtime metadata → definition env (harness author defaults) →
- // global → live persona → per-agent, with reserved-key and malformed-key filtering
- // applied. Writing it last lets user-provided values win over every Buzz-set env
- // written above — reserved keys were already stripped from descriptor.env so they
- // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc.
+ // User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent,
+ // reserved-key filtered. Written last so user-explicit values win over Buzz-set env.
for (key, value) in &descriptor.env {
command.env(key, value);
}
+
+ // B5: carry persisted effort; harness resolves thought_level configId at first session.
+ // Written AFTER descriptor.env so the canonical persisted value wins over any
+ // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern
+ // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is
+ // None there is no canonical value to assert, so env passthrough stands — user env
+ // legitimately seeds startup effort in that case.
+ apply_effort_env(&mut command, record.effort_level.as_deref());
+
+ // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority.
+ // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env
+ // would be ambiguous).
+ if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude")
+ {
+ apply_claude_model_env(&mut command, effective_model.as_deref());
+ }
configure_runtime_cli(&mut command, runtime_meta);
// Buzz shared compute is stored as a native provider; derive the OpenAI-compatible
diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
index 792a275b05..9076766b2e 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
@@ -90,5 +90,6 @@ pub(super) fn fixture(
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
index 357f5f1e26..8a6f68a693 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
@@ -31,6 +31,7 @@ use std::collections::BTreeMap;
use serde::Serialize;
use super::{
+ claude_config::EFFORT_LEVEL_ENV_VAR,
effective_config::{resolve_effective_config, EffectiveConfigResult},
known_acp_runtime, normalize_agent_args,
persona_events::preview_prospective_persona_snapshot,
@@ -126,6 +127,31 @@ pub(crate) struct SpawnConfigSnapshot {
pub idle_timeout_seconds: Option,
pub max_turn_duration_seconds: Option,
pub parallelism: u32,
+ /// The startup effort the harness will actually apply, resolved by
+ /// [`effective_effort`]: the persisted canonical `record.effort_level` when
+ /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered
+ /// env. This is the *sole* representation of effort in the snapshot — the
+ /// key is stripped from `env` (see `from_inputs`) so an authority handoff
+ /// that leaves the effective value unchanged (canonical `low` replacing a
+ /// user env `low`, or the reverse) produces no spurious drift entry, and an
+ /// env-only edit still surfaces as exactly one `effort_level` entry.
+ pub effort_level: Option,
+}
+
+/// The startup effort a spawn would actually apply, mirroring `apply_effort_env`
+/// exactly: the persisted canonical `record.effort_level` wins, and only when it
+/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env
+/// seed startup effort. This is the resolver input for the snapshot's single
+/// `effort_level` representation; the same precedence runs at spawn time in
+/// `runtime.rs`, so badge and process can never disagree.
+pub(crate) fn effective_effort(
+ record: &ManagedAgentRecord,
+ descriptor_env: &BTreeMap,
+) -> Option {
+ record
+ .effort_level
+ .clone()
+ .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned())
}
impl SpawnConfigSnapshot {
@@ -151,7 +177,17 @@ impl SpawnConfigSnapshot {
.and_then(|runtime| runtime.mcp_command)
.unwrap_or("")
.to_string(),
- env: descriptor.env.clone(),
+ // Effort has ONE representation in the snapshot: `effort_level`
+ // below, always holding `effective_effort`. Stripping the env key
+ // here means a canonical/user-env authority handoff at the same
+ // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or
+ // remove) and an env-only effort edit surfaces as exactly one
+ // `effort_level` entry rather than a duplicate under `env.`.
+ env: {
+ let mut env = descriptor.env.clone();
+ env.remove(EFFORT_LEVEL_ENV_VAR);
+ env
+ },
relay_url: relay_url.to_string(),
team_instructions: team_instructions.map(str::to_string),
system_prompt: system_prompt.map(str::to_string),
@@ -178,6 +214,11 @@ impl SpawnConfigSnapshot {
// pool and must badge. The diff surface consequently displays the
// effective value — that is correct, it is what actually runs.
parallelism: super::effective_parallelism(&descriptor.command, record.parallelism),
+ // Sole effort representation — see the field doc and the `env`
+ // strip above. Resolver reads the record's canonical value and the
+ // raw descriptor env (before the strip), so a user-seeded env value
+ // is preserved as the effective effort when no canonical is set.
+ effort_level: effective_effort(record, &descriptor.env),
}
}
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs
index a61eb92e2e..0ae3009bae 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs
@@ -103,6 +103,7 @@ fn policy_for(path: &str) -> MaskPolicy {
// acp_command / command / mcp_command — resolved binary names
// session_title — display chrome
// model / provider — catalog ids
+ // effort_level — non-secret effort enum
// respond_to / respond_to_allowlist — gate mode + pubkeys
// idle_timeout_seconds / max_turn_duration_seconds / parallelism
// — numeric limits
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs
index a7a8cab93e..e21dc4735c 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs
@@ -28,6 +28,7 @@ fn base() -> SpawnConfigSnapshot {
idle_timeout_seconds: Some(600),
max_turn_duration_seconds: Some(7200),
parallelism: 1,
+ effort_level: Some("high".into()),
}
}
@@ -70,6 +71,7 @@ fn mutations() -> Vec {
s.max_turn_duration_seconds = None
}),
("parallelism", |s| s.parallelism = 8),
+ ("effort_level", |s| s.effort_level = None),
]
}
@@ -570,3 +572,37 @@ fn unstamped_agent_yields_no_badge_and_no_entries() {
);
}
}
+
+// ── B5 effort lifecycle: restart-diff and re-stamp ───────────────────────
+
+#[test]
+fn tracked_running_old_effort_edited_to_new_yields_effort_level_diff() {
+ // A process was stamped at effort `high`; the record's canonical effort is
+ // later edited to `low`. Until a restart re-stamps, the tracked pair must
+ // light the badge and name exactly `effort_level`.
+ let stamped = base(); // effort_level = high
+ let mut current = base();
+ current.effort_level = Some("low".into());
+ let (needs_restart, entries) = eligible(false, &stamped, ¤t, None, None);
+ assert!(needs_restart);
+ assert_eq!(fields(&entries), vec!["effort_level"]);
+ assert_eq!(
+ change_at(&entries, "effort_level"),
+ &RestartChange::Value {
+ before: Value::String("high".into()),
+ after: Value::String("low".into()),
+ }
+ );
+}
+
+#[test]
+fn restart_restamps_effort_and_clears_the_badge() {
+ // After the edit above, a restart stamps the new effort, so stamped and
+ // current agree again: the badge clears and no entry remains.
+ let mut restamped = base();
+ restamped.effort_level = Some("low".into());
+ let current = restamped.clone();
+ let (needs_restart, entries) = eligible(false, &restamped, ¤t, None, None);
+ assert!(!needs_restart);
+ assert!(entries.is_empty());
+}
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
index 89bba15cee..b007e0b2ff 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
@@ -34,6 +34,12 @@ fn snapshot(
snapshot_with_policy(record, personas, teams, workspace_relay, global, false)
}
+/// `snapshot` with the fixed no-persona/no-team/default-global shape the effort
+/// tests share, so their call sites read as `snap(&record)` instead of wrapping.
+fn snap(record: &ManagedAgentRecord) -> serde_json::Value {
+ snapshot(record, &[], &[], "wss://ws.example", &Default::default())
+}
+
fn record() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: "p".repeat(64),
@@ -90,6 +96,7 @@ fn record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
@@ -925,3 +932,7 @@ fn openclaw_cap_crossing_parallelism_snapshots_differ() {
"parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots"
);
}
+
+#[cfg(test)]
+#[path = "tests_ext.rs"]
+mod ext;
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs
new file mode 100644
index 0000000000..dd708b6e59
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs
@@ -0,0 +1,189 @@
+//! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold
+//! that file under the 1000-line file-size ratchet.
+//!
+//! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to
+//! its `record`, `snap`, and `record_with_env_effort` helpers.
+
+use super::*;
+
+#[test]
+fn effort_set_then_cleared_round_trips_to_no_effort_projection() {
+ // Persist a canonical effort, then clear it: the projection must return to
+ // the exact no-effort baseline, so the badge lights on set and clears on
+ // clear rather than sticking.
+ let baseline = snap(&record());
+ let mut set = record();
+ set.effort_level = Some("high".into());
+ assert_ne!(baseline, snap(&set), "setting canonical effort must badge");
+ // Clear the SAME record back to None — the projection must return to the
+ // exact no-effort baseline, proving the round-trip clears rather than a
+ // fresh record merely matching baseline.
+ set.effort_level = None;
+ assert_eq!(
+ baseline,
+ snap(&set),
+ "clearing canonical effort restores the no-effort projection"
+ );
+}
+
+#[test]
+fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() {
+ // Canonical `high` shadows the user env seed. Editing that seed low→medium
+ // changes nothing effective (canonical wins and the env key is stripped),
+ // so the projections are identical and no badge lights.
+ let mut low_env = record_with_env_effort("low");
+ low_env.effort_level = Some("high".into());
+ let mut medium_env = record_with_env_effort("medium");
+ medium_env.effort_level = Some("high".into());
+ assert_eq!(
+ snap(&low_env),
+ snap(&medium_env),
+ "editing a canonical-shadowed user env must not badge"
+ );
+}
+
+#[test]
+fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() {
+ // Canonical `high` over a user env seed `low`: clearing the canonical drops
+ // the effective effort to the env fallback `low`, a real change that badges.
+ let mut canonical = record_with_env_effort("low");
+ canonical.effort_level = Some("high".into());
+ let env_only = record_with_env_effort("low");
+ assert_ne!(
+ snap(&canonical),
+ snap(&env_only),
+ "clearing canonical must reveal the env fallback and badge"
+ );
+}
+
+// ── B5 effort: single canonical representation ───────────────────────────
+//
+// `effective_effort` and the snapshot's `effort_level` field are the sole
+// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the
+// snapshot `env` so an authority handoff at an unchanged effective value
+// (canonical replacing a user-env seed, or the reverse) raises no spurious
+// restart badge, while a genuine effort change surfaces exactly once.
+
+/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any.
+fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> {
+ canonical
+ .get("env")
+ .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL"))
+}
+
+/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical
+/// authority: no persisted `effort_level`, effort comes from user env_vars).
+fn record_with_env_effort(value: &str) -> ManagedAgentRecord {
+ let mut rec = record();
+ rec.env_vars
+ .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into());
+ rec
+}
+
+#[test]
+fn effective_effort_prefers_persisted_canonical_over_user_env() {
+ // Canonical wins, mirroring spawn's `apply_effort_env` (written after the
+ // user env layer). The env value is ignored when a canonical is present.
+ let mut rec = record();
+ rec.effort_level = Some("high".into());
+ let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]);
+ assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high"));
+}
+
+#[test]
+fn effective_effort_falls_back_to_user_env_when_no_canonical() {
+ // No persisted canonical → the user-seeded env value is the effective
+ // startup effort, exactly what a spawn would leave in place.
+ let rec = record();
+ let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]);
+ assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low"));
+}
+
+#[test]
+fn effective_effort_is_none_without_canonical_or_env() {
+ assert_eq!(effective_effort(&record(), &BTreeMap::new()), None);
+}
+
+#[test]
+fn snapshot_carries_effort_in_field_not_env() {
+ // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as
+ // the `effort_level` field; the raw env key is stripped so effort has one
+ // representation, never two.
+ let canonical = snap(&record_with_env_effort("low"));
+ assert_eq!(
+ canonical.get("effort_level").and_then(|v| v.as_str()),
+ Some("low"),
+ "effective effort must land in the effort_level field"
+ );
+ assert_eq!(
+ effort_env_leaf(&canonical),
+ None,
+ "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env"
+ );
+}
+
+#[test]
+fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() {
+ // User env `low` (no canonical) → persisted canonical `low` while the env
+ // seed remains: the effective effort is `low` either way, so a restart
+ // would change nothing. Old raw-env snapshots would have shown drift; the
+ // single canonical representation makes the projections identical.
+ let env_authority = record_with_env_effort("low");
+ let mut canonical_authority = record_with_env_effort("low");
+ canonical_authority.effort_level = Some("low".into());
+ assert_eq!(
+ snap(&env_authority),
+ snap(&canonical_authority),
+ "an authority handoff at the same effort value must not badge"
+ );
+}
+
+#[test]
+fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() {
+ // The reverse direction: canonical `low` (env seed present) → env `low`
+ // only (canonical cleared). Effective effort stays `low`; no badge.
+ let mut canonical_authority = record_with_env_effort("low");
+ canonical_authority.effort_level = Some("low".into());
+ let env_authority = record_with_env_effort("low");
+ assert_eq!(
+ snap(&canonical_authority),
+ snap(&env_authority),
+ "clearing the canonical while the env seed holds the same value must not badge"
+ );
+}
+
+#[test]
+fn env_only_effort_edit_changes_effort_level_not_env() {
+ // An env-only effort edit (no canonical) moves the single `effort_level`
+ // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL`
+ // leaf, so the diff names `effort_level` once rather than duplicating it.
+ let low = snap(&record_with_env_effort("low"));
+ let high = snap(&record_with_env_effort("high"));
+ assert_ne!(
+ low, high,
+ "an env-only effort edit must change the snapshot"
+ );
+ assert_eq!(
+ low.get("effort_level").and_then(|v| v.as_str()),
+ Some("low")
+ );
+ assert_eq!(
+ high.get("effort_level").and_then(|v| v.as_str()),
+ Some("high")
+ );
+ assert_eq!(effort_env_leaf(&low), None);
+ assert_eq!(effort_env_leaf(&high), None);
+}
+
+#[test]
+fn canonical_effort_edit_changes_snapshot() {
+ let mut low = record();
+ low.effort_level = Some("low".into());
+ let mut high = record();
+ high.effort_level = Some("high".into());
+ assert_ne!(
+ snap(&low),
+ snap(&high),
+ "a canonical effort edit must trip the restart badge"
+ );
+}
diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs
index 5073d9c407..2b6918b16e 100644
--- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs
@@ -310,6 +310,7 @@ mod tests {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs
index d66e68979c..ff7900d392 100644
--- a/desktop/src-tauri/src/managed_agents/teams_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs
@@ -214,6 +214,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord {
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
+ effort_level: None,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index 3b0641cb67..9049482de3 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -154,6 +154,7 @@ impl AgentDefinition {
definition_respond_to_allowlist: self.respond_to_allowlist,
definition_parallelism: self.parallelism,
relay_mesh: None,
+ effort_level: None,
}
}
}
@@ -439,24 +440,10 @@ pub struct ManagedAgentRecord {
/// deserialize as `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_mesh: Option,
-}
-
-/// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`].
-///
-/// Feature-independent on purpose: the field is always present in the record
-/// schema so saved agents round-trip identically whether or not the `mesh-llm`
-/// feature is compiled in.
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct RelayMeshConfig {
- /// The served model id this agent routes to (e.g. "Qwen3").
- ///
- /// `alias` because this struct crosses two boundaries with different
- /// casing conventions: the TS create request sends camelCase
- /// (`relayMesh: { modelRef }` — `rename_all` on the request does not
- /// recurse into nested structs), while persisted records use snake_case.
- /// Serialization stays `model_ref` so saved records are stable.
- #[serde(alias = "modelRef")]
- pub model_ref: String,
+ /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn
+ /// so the harness applies it via `session/set_config_option` at session creation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effort_level: Option,
}
#[derive(Debug)]
@@ -991,6 +978,8 @@ pub fn resolve_mint_behavioral_defaults(
mod catalog_source;
pub use catalog_source::CatalogSource;
+mod relay_mesh;
+pub use relay_mesh::RelayMeshConfig;
mod requests;
pub use requests::*;
diff --git a/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs
new file mode 100644
index 0000000000..a9ec2d2838
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs
@@ -0,0 +1,19 @@
+use serde::{Deserialize, Serialize};
+
+/// Typed relay-mesh configuration carried on a [`super::ManagedAgentRecord`].
+///
+/// Feature-independent on purpose: the field is always present in the record
+/// schema so saved agents round-trip identically whether or not the `mesh-llm`
+/// feature is compiled in.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RelayMeshConfig {
+ /// The served model id this agent routes to (e.g. "Qwen3").
+ ///
+ /// `alias` because this struct crosses two boundaries with different
+ /// casing conventions: the TS create request sends camelCase
+ /// (`relayMesh: { modelRef }` — `rename_all` on the request does not
+ /// recurse into nested structs), while persisted records use snake_case.
+ /// Serialization stays `model_ref` so saved records are stable.
+ #[serde(alias = "modelRef")]
+ pub model_ref: String,
+}
diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs
index 685f83b799..69b7b9ab1f 100644
--- a/desktop/src-tauri/src/relay.rs
+++ b/desktop/src-tauri/src/relay.rs
@@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String {
/// Read the workspace relay URL override, if set. Returns `None` when no
/// override is active or when the mutex is poisoned (best-effort).
-fn workspace_relay_override(state: &AppState) -> Option {
+pub(crate) fn workspace_relay_override(state: &AppState) -> Option {
state
.relay_url_override
.lock()
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 6257a75b72..96aa7acbac 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -894,6 +894,7 @@ export function AppShell() {
/>
) : null}
;
@@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = {
export function AppShellChannelSurface({
children,
+ hasCommunityRail,
isHuddleRoom,
isHuddleRoomStarting,
mainInsetRef,
terminal,
}: AppShellChannelSurfaceProps) {
+ const { isMobile, openMobile, state: sidebarState } = useSidebar();
+ const hasCollapsedSidebarGutter =
+ !isHuddleRoom &&
+ !hasCommunityRail &&
+ (isMobile ? !openMobile : sidebarState === "collapsed");
+
return (
+ {hasCollapsedSidebarGutter ? (
+
+ ) : null}
{isHuddleRoom && !isHuddleRoomStarting ? : null}
{isHuddleRoomStarting ? : children}
diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts
index cda6c0f2ed..e8b9320794 100644
--- a/desktop/src/app/useWebviewZoomShortcuts.ts
+++ b/desktop/src/app/useWebviewZoomShortcuts.ts
@@ -1,13 +1,13 @@
import * as React from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
+import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
const DEFAULT_ZOOM_FACTOR = 1;
const MIN_ZOOM_FACTOR = 0.75;
const MAX_ZOOM_FACTOR = 1.5;
const ZOOM_STEP = 0.1;
-const BASE_FONT_SIZE_PX = 16;
const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
type ZoomAction = "increase" | "decrease" | "reset";
@@ -76,13 +76,12 @@ function readStoredZoomFactor() {
}
function applyTextScale(zoomFactor: number) {
+ applyTextZoomFactor(zoomFactor);
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
- document.documentElement.style.fontSize = "";
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
return;
}
- document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
}
@@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() {
applyTextScale(nextZoomFactor);
}
+ function handleStorage(event: StorageEvent) {
+ if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) {
+ return;
+ }
+
+ const storedZoomFactor = readStoredZoomFactor();
+ zoomFactorRef.current = storedZoomFactor;
+ applyTextZoomFactor(storedZoomFactor);
+ }
+
window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("storage", handleStorage);
};
}, []);
}
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index fa352284dd..88f2a3c982 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -201,6 +201,40 @@ with a TypeScript lookup table or an id comparison in a component.
fields, and profile-wide activity selection. Caller context may control the
panel shell or return navigation, but must not filter or replace profile
content.
+14. **Thinking effort has two surfaces: a local-only WRITE control and a
+ read-only two-facts DISPLAY.** The write control is `EffortPickerField`
+ (`ui/EffortPickerField.tsx`), a self-contained section component mounted in
+ `AgentInstanceEditDialog` beside the Model block. It is direct-write, not
+ part of the frozen `UpdateManagedAgentInput` shape: each selection calls
+ `persistAgentEffortLevel` and invalidates the config-surface query, mirroring
+ the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and
+ option compute live in the pure helper `ui/effortPicker.ts`
+ (`effortPickerState`): the picker renders only when
+ `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId`
+ has been discovered from the running session (absent pre-first-session and
+ for runtimes/models without effort support). Local-only is load-bearing, not
+ cosmetic — the Rust command rejects non-local backends because remote effort
+ is set at deploy time via `policy_env`. Because it reads its inputs from the
+ config surface the dialog already fetches (`useAgentConfigSurface`) and owns
+ its own mutation, it does **not** thread new props through the over-1000-line
+ dialog (see rule 11): keep effort state inside the section component, never
+ as dialog-level props. The read-only display is the `thinkingEffort`
+ normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which
+ already shows both facts — `field.value` (canonical, the effort the next
+ spawn will launch with) and, when a running ACP session differs,
+ `field.overriddenValue` struck through (the live session's current effort).
+ No component owns "configured vs current" logic; the reader's canonical tier
+ ordering feeds both facts. Do not add a second effort write path or restate
+ the two-facts logic in a component.
+
+ **Cut invariant — live mid-conversation effort machinery was deliberately
+ removed.** Effort is spawn-scoped only: the worker holds one `startup_effort`
+ read from `BUZZ_ACP_EFFORT_LEVEL` and applies it once at session creation
+ (`apply_startup_effort` in `buzz-acp/src/pool.rs`); there is no pool-level
+ effort authority, no live effort switching, and no effort-ack frame. Do not
+ reintroduce a live effort-switch RPC, a pool effort field, or a
+ mid-conversation effort control without a plan ruling. The archived live-effort
+ machinery lives on `archive/claude-config-gaps-live-effort` for reference only.
12. **Owner-only builds discover only verified same-owner remote agents.**
The native `list_relay_agents` boundary authenticates ownership through the
@@ -242,6 +276,11 @@ with a TypeScript lookup table or an id comparison in a component.
every profile tab when opened from Agents and from the agent's DM.
- `ui/AgentConfigPanelPresentation.test.mjs` — shared profile/agent config rows
show only effective values, with an em dash for unknown values.
+- `ui/effortPicker.test.mjs` — `effortPickerState` gating (local + discovered
+ `effortConfigId` renders; provider backend or missing configId hides) and
+ option/preselect compute, plus `effortSelectionToPersistedValue` sentinel →
+ null. This is where the v4 provider regression is pinned: the write control
+ must never render for a provider backend.
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
acceptance coverage for readiness, failure states, defaults, session-draft
restoration, zero-write Skip, Next save failure/retry, navigation, and
diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs
index 737d84b862..4a79d32837 100644
--- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs
+++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs
@@ -3,10 +3,18 @@ import test from "node:test";
import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts";
-const MODEL = "goose-claude-fable-5";
+const REQUEST_ID = "req-abc";
+const CH_A = "channel-a";
+const CH_B = "channel-b";
function frame(status, overrides = {}) {
- return { type: "switch_model", status, modelId: MODEL, ...overrides };
+ return {
+ type: "switch_model",
+ status,
+ requestId: REQUEST_ID,
+ channelId: CH_A,
+ ...overrides,
+ };
}
/**
@@ -15,7 +23,7 @@ function frame(status, overrides = {}) {
* no-ops, matching `observerRelayStore`), a manual timeout, and a deferred
* `sendSwitches` the test resolves explicitly.
*/
-function harness(channelCount) {
+function harness(channelIds, requestId = REQUEST_ID) {
let listener = null;
let timeoutCb = null;
let unsubscribeCalls = 0;
@@ -26,8 +34,8 @@ function harness(channelCount) {
});
const outcome = awaitLiveSwitchOutcome({
- channelCount,
- modelId: MODEL,
+ requestId,
+ channelIds,
subscribe: (fn) => {
listener = fn;
return () => {
@@ -61,47 +69,85 @@ function harness(channelCount) {
};
}
+const drainMicrotasks = async () => {
+ for (let i = 0; i < 5; i++) {
+ await Promise.resolve();
+ }
+};
+
test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => {
- const h = harness(2);
+ const h = harness([CH_A, CH_B]);
// Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok"
// here. The fail-fast contract must keep waiting and then reject on B.
h.push(frame("sent"));
- h.push(frame("unsupported_model"));
+ h.push(frame("unsupported_model", { channelId: CH_B }));
assert.equal(await h.outcome, "unsupported");
});
-test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => {
- const h = harness(3);
+test("awaitLiveSwitchOutcome resolves ok only after every distinct channel acks", async () => {
+ const h = harness([CH_A, CH_B]);
let settled = false;
void h.outcome.then(() => {
settled = true;
});
- // The `.then` that flips `settled` flushes on a later microtask tick than a
- // single drain, so a single `await Promise.resolve()` would let this
- // assertion pass even against a first-ack-resolves bug. Draining several
- // ticks guarantees a resolved promise's callback has run, so the interim
- // `settled === false` checks deterministically regress an early resolve.
- const drainMicrotasks = async () => {
- for (let i = 0; i < 5; i++) {
- await Promise.resolve();
- }
- };
-
- h.push(frame("sent"));
+ // Terminal success for channel A alone must not settle a two-channel pick.
+ h.push(frame("switched", { channelId: CH_A }));
await drainMicrotasks();
- assert.equal(settled, false, "must not resolve on the first ack");
+ assert.equal(settled, false, "must not resolve before every channel acks");
+
+ h.push(frame("switched", { channelId: CH_B }));
+ assert.equal(await h.outcome, "ok");
+});
+
+test("awaitLiveSwitchOutcome settles not_delivered immediately on a turn_ending frame without waiting for other channels or the timeout", async () => {
+ // `turn_ending` means the control oneshot was already consumed (a prior
+ // cancel is ending the turn) — the switch can't land and nothing applies
+ // later. It must fail-fast to "not_delivered", NOT count as a positive
+ // terminal. Three channels prove it never traverses the success-count path.
+ const h = harness([CH_A, CH_B, "channel-c"]);
+ h.push(frame("turn_ending"));
+ assert.equal(await h.outcome, "not_delivered");
+ assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
+ assert.equal(h.unsubscribeCalls, 1);
+
+ // A later positive frame must not re-resolve or re-unsubscribe.
+ h.push(frame("switched"));
+ assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
+});
+
+test("awaitLiveSwitchOutcome settles not_delivered immediately on a no_active_turn frame", async () => {
+ // `no_active_turn` means neither an in-flight task nor an idle session-owning
+ // agent existed by the time the harness received the switch (a stale
+ // `activeTurns` snapshot). Nothing was applied and nothing rides a later
+ // session — fail-fast to "not_delivered", never a false "ok".
+ const h = harness([CH_A, CH_B]);
+ h.push(frame("no_active_turn"));
+ assert.equal(await h.outcome, "not_delivered");
+ assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
+ assert.equal(h.unsubscribeCalls, 1);
+});
+
+test("awaitLiveSwitchOutcome ignores an unknown future status and settles via a real switched terminal", async () => {
+ // A status the picker doesn't know (a newer harness) must be inert — never
+ // default-counted as success. The pick stays open until a real `switched`
+ // terminal (or the timeout) settles it.
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("some_future_status"));
+ await drainMicrotasks();
+ assert.equal(settled, false, "an unknown status must not settle the pick");
h.push(frame("switched"));
- await drainMicrotasks();
- assert.equal(settled, false, "must not resolve before the last ack");
-
- h.push(frame("turn_ending"));
assert.equal(await h.outcome, "ok");
});
test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes exactly once", async () => {
- const h = harness(2);
+ const h = harness([CH_A, CH_B]);
h.push(frame("unsupported_model"));
assert.equal(await h.outcome, "unsupported");
assert.equal(h.unsubscribeCalls, 1);
@@ -113,42 +159,309 @@ test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes
assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
});
-test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => {
- const h = harness(1);
- h.push(frame("sent", { modelId: "some-other-model" }));
- h.push({ type: "cancel_turn", status: "sent", modelId: MODEL });
+test("awaitLiveSwitchOutcome settles failed immediately on an adapter-refused frame without waiting for other channels or the timeout", async () => {
+ const h = harness([CH_A, CH_B, "channel-c"]);
+ // Three channels, so a success-path impl would need three acks. A single
+ // `failure` frame must fail-fast to "failed" (not "unsupported", not "ok")
+ // before the other two channels reply — proving it never traverses the
+ // success-count path.
+ h.push(frame("failure"));
+ assert.equal(await h.outcome, "failed");
+ // The timeout was cancelled (no 8s wait) and the listener detached exactly
+ // once — the frame settled synchronously, not via the fallback.
+ assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
+ assert.equal(h.unsubscribeCalls, 1);
+
+ // A later frame must not re-resolve or re-unsubscribe.
+ h.push(frame("switched"));
+ assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
+});
+
+test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then settles failed when the adapter rejection arrives", async () => {
+ // The real busy-path producer order: the harness acks `sent` immediately
+ // (the switch was delivered to the in-flight turn), then — after the requeued
+ // session consults the adapter — emits `failure` seconds later. With one
+ // active channel a first-ack-resolves impl would settle "ok" on `sent` and
+ // detach before `failure` arrives; this regresses that.
+ const h = harness([CH_A]);
let settled = false;
void h.outcome.then(() => {
settled = true;
});
- await Promise.resolve();
+
+ h.push(frame("sent"));
+ await drainMicrotasks();
+ assert.equal(settled, false, "provisional `sent` must not resolve the pick");
+ assert.equal(h.unsubscribeCalls, 0, "subscription stays alive after `sent`");
+ assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`");
+
+ h.push(frame("failure"));
+ assert.equal(await h.outcome, "failed");
+ assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
+ assert.equal(h.unsubscribeCalls, 1);
+});
+
+test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then resolves pending via the timeout when no positive terminal arrives", async () => {
+ // Busy-path success now emits a positive `switched` terminal when the
+ // requeued session applies the model — but a busy turn can outlast the
+ // fallback timeout. If no terminal arrives in time, the pick resolves
+ // `"pending"` (accepted, apply deferred), NEVER a false `"ok"`.
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("sent"));
+ await drainMicrotasks();
+ assert.equal(settled, false, "provisional `sent` must not resolve the pick");
+ assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`");
+
+ h.fireTimeout();
+ assert.equal(await h.outcome, "pending");
+ assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes");
+});
+
+test("awaitLiveSwitchOutcome resolves ok when the busy-path deferred apply emits a positive switched terminal", async () => {
+ // The K1 mirror case: after the provisional `sent`, the requeued session
+ // applies the model and the harness emits a real `switched` terminal before
+ // the timeout. That positive frame — not timeout silence — resolves `"ok"`.
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("sent"));
+ await drainMicrotasks();
+ assert.equal(settled, false, "provisional `sent` must not resolve the pick");
+
+ h.push(frame("switched"));
+ assert.equal(await h.outcome, "ok");
+ assert.equal(
+ h.cancelTimeoutCalls,
+ 1,
+ "positive terminal cancels the timeout",
+ );
+ assert.equal(h.unsubscribeCalls, 1);
+});
+
+test("awaitLiveSwitchOutcome never resolves ok when a delayed rejection lands after the timeout already resolved pending", async () => {
+ // The K1 named pin: a busy switch whose turn outlasts the timeout. The pick
+ // resolves `"pending"` at the timeout; the deferred apply then rejects. The
+ // late `failure` frame must not re-resolve, and the outcome is never `"ok"`.
+ const h = harness([CH_A]);
+
+ h.push(frame("sent"));
+ h.fireTimeout();
+ assert.equal(await h.outcome, "pending");
+ assert.equal(h.unsubscribeCalls, 1, "timeout fallback detaches the listener");
+
+ // Deferred apply rejects after the fact: inert, the listener is gone.
+ h.push(frame("failure"));
+ assert.equal(
+ h.unsubscribeCalls,
+ 1,
+ "no re-resolve or re-unsubscribe on a late frame",
+ );
+});
+
+test("awaitLiveSwitchOutcome ignores frames for a different request id or control type", async () => {
+ const h = harness([CH_A]);
+ // A replayed terminal frame from an EARLIER pick carries a different
+ // requestId; it must not advance this pick's count.
+ h.push(frame("switched", { requestId: "req-stale" }));
+ h.push({
+ type: "cancel_turn",
+ status: "sent",
+ requestId: REQUEST_ID,
+ channelId: CH_A,
+ });
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+ await drainMicrotasks();
assert.equal(settled, false, "unrelated frames must not advance the count");
h.push(frame("switched"));
assert.equal(await h.outcome, "ok");
});
-test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => {
- const h = harness(2);
+test("awaitLiveSwitchOutcome resolves pending via the timeout fallback when the harness never replies", async () => {
+ const h = harness([CH_A, CH_B]);
h.fireTimeout();
- assert.equal(await h.outcome, "ok");
+ assert.equal(await h.outcome, "pending");
assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes");
});
test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => {
- const h = harness(1);
+ const h = harness([CH_A]);
// The subscription is registered before the sends fire, so a frame arriving
- // mid-send is never dropped. Awaiting sendStarted proves sends ran.
+ // mid-send is never dropped. Awaiting sendStarted proves sends ran. Uses a
+ // terminal-success frame (`switched`) since the provisional `sent` no longer
+ // settles the pick on its own.
await h.sendStarted;
- h.push(frame("sent"));
+ h.push(frame("switched"));
assert.equal(await h.outcome, "ok");
});
-test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no acks expected)", async () => {
- // No active turns means channelCount 0: remaining starts at 0 but the success
- // resolve only fires inside a frame callback, so with no frames the timeout
- // fallback is what settles it. This documents the degenerate path.
- const h = harness(0);
+test("awaitLiveSwitchOutcome with zero channels resolves pending at the timeout (no acks expected)", async () => {
+ // No active turns means an empty channel set: the success resolve only fires
+ // inside a frame callback keyed on an expected channel, so with no frames the
+ // timeout fallback is what settles it — to `"pending"`, since no positive
+ // terminal confirmed. This documents the degenerate path.
+ const h = harness([]);
h.fireTimeout();
+ assert.equal(await h.outcome, "pending");
+});
+
+test("awaitLiveSwitchOutcome ignores a reconnect replay of an identical terminal frame", async () => {
+ // The observer relay requests a five-minute replay on reconnect, so the SAME
+ // terminal frame for one channel can arrive twice. A scalar count would treat
+ // the replay as a second channel's ack and settle a two-channel pick early.
+ const h = harness([CH_A, CH_B]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("switched", { channelId: CH_A }));
+ h.push(frame("switched", { channelId: CH_A })); // replay of the same frame
+ await drainMicrotasks();
+ assert.equal(
+ settled,
+ false,
+ "a duplicated channel-A success must not stand in for channel B",
+ );
+
+ h.push(frame("switched", { channelId: CH_B }));
+ assert.equal(await h.outcome, "ok");
+});
+
+test("awaitLiveSwitchOutcome does not let one channel's duplicated success mask another channel's later failure", async () => {
+ // Two channels: A succeeds and its frame is replayed; B rejects late. A
+ // per-frame count would resolve "ok" on A's duplicate before B's failure and
+ // report a false success. Counting per distinct channel keeps the pick open
+ // for B, which fail-fasts to "failed".
+ const h = harness([CH_A, CH_B]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("switched", { channelId: CH_A }));
+ h.push(frame("switched", { channelId: CH_A })); // duplicate for A
+ await drainMicrotasks();
+ assert.equal(settled, false, "A's duplicate must not complete the pick");
+
+ h.push(frame("failure", { channelId: CH_B }));
+ assert.equal(await h.outcome, "failed");
+});
+
+test("awaitLiveSwitchOutcome ignores a stale result from an overlapping same-model operation", async () => {
+ // Two picks for the same model overlap: this operation is `req-new`; a prior
+ // `req-old` pick's terminal frame (same model, same channel) is still in
+ // flight. Correlating on requestId — not modelId — keeps the old result from
+ // settling the new pick.
+ const h = harness([CH_A], "req-new");
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push({
+ type: "switch_model",
+ status: "switched",
+ requestId: "req-old",
+ channelId: CH_A,
+ });
+ await drainMicrotasks();
+ assert.equal(settled, false, "a prior same-model pick's result is inert");
+
+ h.push({
+ type: "switch_model",
+ status: "switched",
+ requestId: "req-new",
+ channelId: CH_A,
+ });
+ assert.equal(await h.outcome, "ok");
+});
+
+// The channel guard must run BEFORE status handling so a negative frame is
+// correlated by channel too — a misrouted or channel-less `failure`/
+// `unsupported_model` carrying this pick's requestId must not fail it. This is
+// the false-failure mirror of the false-success class the positive-terminal
+// channel count already guards.
+test("awaitLiveSwitchOutcome ignores a failure frame from a foreign channel", async () => {
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ // Same requestId, but a channel this pick never fired to: inert.
+ h.push(frame("failure", { channelId: "channel-foreign" }));
+ await drainMicrotasks();
+ assert.equal(
+ settled,
+ false,
+ "a foreign-channel failure must not fail the pick",
+ );
+
+ h.push(frame("switched", { channelId: CH_A }));
+ assert.equal(await h.outcome, "ok");
+});
+
+test("awaitLiveSwitchOutcome ignores an unsupported_model frame from a foreign channel", async () => {
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("unsupported_model", { channelId: "channel-foreign" }));
+ await drainMicrotasks();
+ assert.equal(
+ settled,
+ false,
+ "a foreign-channel unsupported_model must not fail the pick",
+ );
+
+ h.push(frame("switched", { channelId: CH_A }));
+ assert.equal(await h.outcome, "ok");
+});
+
+test("awaitLiveSwitchOutcome ignores a failure frame that carries no channel", async () => {
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("failure", { channelId: undefined }));
+ await drainMicrotasks();
+ assert.equal(settled, false, "a channel-less failure must not fail the pick");
+
+ h.push(frame("switched", { channelId: CH_A }));
+ assert.equal(await h.outcome, "ok");
+});
+
+test("awaitLiveSwitchOutcome ignores an unsupported_model frame that carries no channel", async () => {
+ const h = harness([CH_A]);
+ let settled = false;
+ void h.outcome.then(() => {
+ settled = true;
+ });
+
+ h.push(frame("unsupported_model", { channelId: undefined }));
+ await drainMicrotasks();
+ assert.equal(
+ settled,
+ false,
+ "a channel-less unsupported_model must not fail the pick",
+ );
+
+ h.push(frame("switched", { channelId: CH_A }));
assert.equal(await h.outcome, "ok");
});
diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts
index d12261e596..83792dcdab 100644
--- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts
+++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts
@@ -4,57 +4,146 @@ import type { ControlResultFrame } from "@/shared/api/types";
* Resolve the outcome of a live `switch_model` across one or more channels.
*
* A live switch fires a `switch_model` frame per active channel and learns each
- * channel's result asynchronously over the observer relay. The fail-fast rule:
- * any single `unsupported_model` result rejects the whole pick immediately;
- * every other status must arrive from every channel before resolving success.
- * If the harness never replies, the fallback timeout resolves `"ok"` — the
- * override still rides the requeued/next session, we just can't confirm it
- * synchronously.
+ * channel's result asynchronously over the observer relay. Two statuses
+ * fail-fast — any single frame rejects the whole pick immediately, without
+ * waiting for the other channels or the timeout:
+ * - `unsupported_model` → the target model isn't available for this agent.
+ * - `failure` → the adapter refused the switch (the session stays
+ * on its current model).
+ * Their causes differ, so they resolve to distinct outcomes (`"unsupported"`
+ * vs `"failed"`) the caller can message separately.
+ *
+ * `sent` is the busy-path PROVISIONAL ack: the switch was delivered to the
+ * in-flight turn, but the adapter isn't consulted until the requeued session
+ * runs. The real verdict lands later as a positive `switched` terminal (the
+ * deferred apply succeeded), a `failure`/`unsupported_model` frame (it didn't),
+ * so `sent` never settles the pick on its own — the subscription stays alive
+ * for the terminal frame.
+ *
+ * Success is only ever inferred from `switched` — the one status that means
+ * the model was APPLIED — which must arrive from every EXPECTED channel before
+ * resolving `"ok"`. The idle path emits it immediately; the busy path emits it
+ * when the requeued session applies the model. A busy turn routinely outlasts
+ * the fallback timeout, so the timeout NEVER resolves `"ok"` — it resolves
+ * `"pending"`: the switch was accepted and rides the requeued/next session, but
+ * we could not confirm the apply synchronously. The caller surfaces that
+ * truthfully rather than claiming a success that has not happened (and might
+ * yet be rejected).
+ *
+ * Two more statuses are non-delivery terminals — the harness never set the
+ * desired model and nothing applies later, so they can no more resolve `"ok"`
+ * than a `failure` can:
+ * - `turn_ending` → the control oneshot was already consumed (a prior
+ * cancel is ending the turn), so the switch can't land.
+ * - `no_active_turn` → neither an in-flight task nor an idle session-owning
+ * agent existed (a stale `activeTurns` snapshot between
+ * the picker read and the harness receipt).
+ * Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID
+ * ride the requeued session): here the switch never landed at all.
+ *
+ * Any other status — a `sent` provisional ack, or an unknown future status — is
+ * inert: it is never counted as success. A new producer status that should
+ * settle the pick must add its own explicit branch.
+ *
+ * Two identity guards keep a stale or replayed frame from settling the wrong
+ * pick. The observer relay requests a five-minute replay on reconnect, so an
+ * old `control_result` for an earlier switch can re-arrive mid-pick:
+ * - `requestId` — an opaque per-pick correlator the harness echoes on every
+ * frame. Frames without a matching id are ignored, so a replayed result for
+ * a prior operation (which carried a different id, or none) is inert.
+ * - `channelId` — every frame, positive OR negative, must name a channel in
+ * the EXPECTED set before it can settle anything. A misrouted `failure`
+ * from a foreign channel (or a frame with no channel) can no more fail the
+ * pick than a foreign `switched` can satisfy it. Positive terminals are
+ * then counted once per DISTINCT expected channel, not once per frame, so
+ * two copies of one channel's `switched` can't satisfy a two-channel pick.
*
* The counting lives here, isolated from React and the relay so it can be unit
* tested with synthetic frames and a fake clock. The caller injects the
* relay subscription, the per-channel sends, and the timeout scheduler.
*/
export async function awaitLiveSwitchOutcome({
- channelCount,
- modelId,
+ requestId,
+ channelIds,
subscribe,
sendSwitches,
scheduleTimeout,
}: {
- /** Number of channels the switch was fired to — the success threshold. */
- channelCount: number;
- /** Model being switched to; frames for any other model are ignored. */
- modelId: string;
+ /** Opaque per-pick id; frames without this exact id are ignored. */
+ requestId: string;
+ /** Channels the switch was fired to — the distinct set to await. */
+ channelIds: readonly string[];
/** Register a control-result listener; returns an unsubscribe function. */
subscribe: (listener: (frame: ControlResultFrame) => void) => () => void;
/** Fire the per-channel `switch_model` sends. Resolves when all are sent. */
sendSwitches: () => Promise;
/** Schedule the no-reply fallback; returns a cancel function. */
scheduleTimeout: (onTimeout: () => void) => () => void;
-}): Promise<"ok" | "unsupported"> {
- const settled = new Promise<"ok" | "unsupported">((resolve) => {
+}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> {
+ const expected = new Set(channelIds);
+ const settled = new Promise<
+ "ok" | "unsupported" | "failed" | "not_delivered" | "pending"
+ >((resolve) => {
let unsubscribe = () => {};
let cancelTimeout = () => {};
- let remaining = channelCount;
- const finish = (outcome: "ok" | "unsupported") => {
+ const succeeded = new Set();
+ const finish = (
+ outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending",
+ ) => {
cancelTimeout();
unsubscribe();
resolve(outcome);
};
- cancelTimeout = scheduleTimeout(() => finish("ok"));
+ // No positive terminal in time: the switch was accepted but its deferred
+ // apply hasn't confirmed. Resolve indeterminate — never a false "ok".
+ cancelTimeout = scheduleTimeout(() => finish("pending"));
unsubscribe = subscribe((frame) => {
- if (frame.type !== "switch_model" || frame.modelId !== modelId) {
+ // Two identity guards run BEFORE any status handling, so they scope
+ // every decision — positive AND negative — to THIS pick's channels:
+ // - requestId: a replayed result for a prior operation carries a
+ // different id (or none) and is ignored.
+ // - channelId: the frame must name an EXPECTED channel. A negative
+ // frame (`failure`/`unsupported_model`) misrouted from a foreign
+ // channel, or carrying no channel at all, must not fail this pick
+ // any more than a foreign positive frame may satisfy it.
+ if (frame.type !== "switch_model" || frame.requestId !== requestId) {
+ return;
+ }
+ if (!frame.channelId || !expected.has(frame.channelId)) {
return;
}
if (frame.status === "unsupported_model") {
- // Any single failure rejects the whole pick immediately.
+ // Model unavailable — reject the whole pick immediately.
finish("unsupported");
return;
}
- // sent / switched / turn_ending — count as success for this channel.
- remaining -= 1;
- if (remaining <= 0) {
+ if (frame.status === "failure") {
+ // Adapter refused the switch — reject immediately. The session stays
+ // on its current model; distinct outcome so the caller can say why.
+ finish("failed");
+ return;
+ }
+ if (frame.status === "turn_ending" || frame.status === "no_active_turn") {
+ // Non-delivery terminal: the harness never set the desired model and
+ // nothing applies later (`turn_ending` = the control oneshot was
+ // already consumed; `no_active_turn` = no in-flight task and no idle
+ // session-owning agent). Fail-fast, distinct from `"pending"` — here
+ // the switch never landed at all.
+ finish("not_delivered");
+ return;
+ }
+ if (frame.status !== "switched") {
+ // Anything else — the provisional `sent` ack, or an unknown future
+ // status — is inert. Only `switched` (the model was APPLIED) counts.
+ // A busy `sent` is settled later by its own `switched`/`failure`
+ // terminal, or by the timeout resolving `"pending"`.
+ return;
+ }
+ // `switched` — the model was applied for this channel. Count each
+ // expected channel once: a duplicate frame for a channel already
+ // recorded (a replay, or a two-copy fan-out) is a no-op.
+ succeeded.add(frame.channelId);
+ if (succeeded.size >= expected.size) {
finish("ok");
}
});
diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts
index 88e8bba754..68fa290ad2 100644
--- a/desktop/src/features/agents/observerRelayStore.ts
+++ b/desktop/src/features/agents/observerRelayStore.ts
@@ -272,7 +272,7 @@ function appendAgentEvents(
}
if (added.length === 0) return null;
- const sortedAdded = added.sort(compareObserverEvents);
+ const sortedAdded = [...added].sort(compareObserverEvents);
const sorted = allAtEnd
? [...current, ...sortedAdded]
: [...current, ...sortedAdded].sort(compareObserverEvents);
@@ -468,9 +468,19 @@ function processLiveObserverEvents(
// callbacks. Those callbacks historically observed their triggering frame
// in the raw/transcript stores; batching must preserve that visibility while
// deferring only the global external-store publication.
- const addedEvents = appendAgentEvents(agentPubkey, events);
+ //
+ // Dispatch iterates the ACCEPTED events, not the raw envelope: the observer
+ // relay requests a five-minute replay on reconnect, so an already-seen frame
+ // can re-arrive. `appendAgentEvents` drops those as duplicates and returns
+ // only the newly-accepted set; dispatching that set keeps a replayed
+ // `control_result` from re-settling a live model switch, and likewise
+ // prevents any other side-effect listener (latest-live tracking, management
+ // requests, session-config capture, lifecycle) from firing twice for one
+ // frame. Every such listener is a command or idempotent cache write — none
+ // depends on duplicate re-delivery — so deduping is strictly correct.
+ const accepted = appendAgentEvents(agentPubkey, events);
- for (const parsed of events) {
+ for (const parsed of accepted ?? []) {
// Track the latest-live-session-id per (agent, channel) on the live path.
// Only set when the parsed event carries both a sessionId and channelId,
// so we never attribute a session to the wrong channel.
@@ -500,7 +510,9 @@ function processLiveObserverEvents(
void putAgentSessionConfig(agentPubkey, parsed.payload);
onSessionConfigCaptured?.(agentPubkey);
} else if (parsed.kind === "control_result") {
- dispatchControlResult(agentPubkey, parsed.payload);
+ // Thread the envelope's channelId into the frame so the ModelPicker can
+ // count a terminal switch result once per distinct channel.
+ dispatchControlResult(agentPubkey, parsed.payload, parsed.channelId);
} else if (parsed.kind === "managed_agent_runtime_lifecycle") {
void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch(
(error) => {
@@ -512,8 +524,8 @@ function processLiveObserverEvents(
// Preserve the harness's envelope backpressure: retained state was committed
// before specialized callbacks, but external-store subscribers publish once.
- if (addedEvents) {
- notifyListeners({ agentPubkey, events: addedEvents });
+ if (accepted) {
+ notifyListeners({ agentPubkey, events: accepted });
}
}
@@ -639,7 +651,11 @@ function isControlResultFrame(payload: unknown): payload is ControlResultFrame {
);
}
-function dispatchControlResult(agentPubkey: string, payload: unknown) {
+function dispatchControlResult(
+ agentPubkey: string,
+ payload: unknown,
+ channelId: string | null,
+) {
if (!isControlResultFrame(payload)) {
return;
}
@@ -647,8 +663,13 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) {
if (!subscribers) {
return;
}
+ // The channelId lives on the observer envelope, not the inner payload, so
+ // stamp it onto the frame here. Listeners (the ModelPicker) count a terminal
+ // switch result once per distinct channel; the envelope is the only place a
+ // late `control_result` carries its channel identity.
+ const frame: ControlResultFrame = { ...payload, channelId };
for (const subscriber of subscribers) {
- subscriber(payload);
+ subscriber(frame);
}
}
diff --git a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
index c2422466e4..b47cee5e3c 100644
--- a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
@@ -143,7 +143,10 @@ function AgentCardViewerContent({
toast.success(`Sent ${agentName}'s card.`);
closeCardViewer();
} else if (sent === false) {
- toast.error("Couldn’t send the card. Try again.");
+ toast.error(
+ sendController.getCurrentError() ??
+ "Couldn’t send the card. Try again.",
+ );
}
}
diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx
index 046bd13c47..e5f3a74530 100644
--- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx
+++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx
@@ -289,6 +289,30 @@ function ProfileConfigSection({
);
}
+/**
+ * #3493: caveat shown when the surface was read from a user-set
+ * `CLAUDE_CONFIG_DIR`. Claude Code keys its stored login to the config-dir
+ * path, so a custom dir maps to a fresh Keychain namespace — the agent starts
+ * logged out unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to match the
+ * default login.
+ */
+function ClaudeConfigDirNotice() {
+ return (
+
+
+ ⚠ Custom CLAUDE_CONFIG_DIR{" "}
+ active — config is read from that directory. Claude Code keys its login
+ to the config-dir path, so a custom dir creates a new Keychain
+ namespace. The agent will need to re-authenticate unless you also set{" "}
+
+ CLAUDE_SECURESTORAGE_CONFIG_DIR
+ {" "}
+ to match your default login.
+
+
+ );
+}
+
export function AgentConfigPanel({
advancedMode = "collapsed",
onEdit,
@@ -355,7 +379,9 @@ export function AgentConfigSurfaceRows({
}: AgentConfigSurfaceRowsProps) {
const [advancedOpen, setAdvancedOpen] = React.useState(false);
- const { normalized, advanced, extensions, runtimeId } = data;
+ const { normalized, advanced, extensions, runtimeId, sources } = data;
+ const mcpConfigFilePath = sources.mcpConfigFilePath;
+ const claudeConfigDirCustom = data.claudeConfigDirCustom ?? false;
const normalizedEntries = (
Object.entries(normalized) as [
@@ -414,6 +440,7 @@ export function AgentConfigSurfaceRows({
>
@@ -430,6 +457,8 @@ export function AgentConfigSurfaceRows({
))}
) : null}
+
+ {claudeConfigDirCustom ? : null}
);
}
@@ -457,6 +486,7 @@ export function AgentConfigSurfaceRows({
@@ -485,6 +515,8 @@ export function AgentConfigSurfaceRows({
) : null}
) : null}
+
+ {claudeConfigDirCustom ? : null}
);
}
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
index f7ee098833..62d85d385d 100644
--- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
@@ -25,6 +25,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
+import { EffortPickerField } from "./EffortPickerField";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
ADVANCED_FIELDS_MOTION_TRANSITION,
@@ -267,10 +268,10 @@ export function AgentInstanceEditDialog({
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
}, [runtimes, originalAgentCommand]);
- // The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime
- // (that is what runs once the override is cleared, not the current override).
- // Falls back to dual-match (command path, then id) when no persona or its runtime is unset.
- // This single prospective id feeds BOTH the block-save gate and submit so they always agree.
+ // The runtime id active after submit — the single prospective id feeding BOTH
+ // the block-save gate and submit so they always agree. Inheriting resolves
+ // from the LINKED PERSONA's runtime (what runs once the override is cleared),
+ // falling back to dual-match (command path, then id) when no persona.
const prospectiveRuntimeId = React.useMemo(() => {
if (!inheritHarness) {
return selectedRuntime?.id ?? selectedRuntimeId;
@@ -425,11 +426,10 @@ export function AgentInstanceEditDialog({
selectedRuntime,
});
- // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
- // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
- // D2/D3: the top-level API key owns display, while the readiness gate keeps
- // the complete required-key list. The effective snapshot covers persona
- // inheritance during an instance inherit transition.
+ // D2/D3: the top-level API key owns display while the readiness gate keeps the
+ // complete required-key list; advancedRequiredEnvKeys drives EnvVarsEditor
+ // display only. The effective snapshot covers persona inheritance during an
+ // instance inherit transition.
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
const personaSatisfied =
providerApiKeyEnvVar != null &&
@@ -693,11 +693,9 @@ export function AgentInstanceEditDialog({
: normalizedModel !== (agent.model ?? null)
? normalizedModel
: undefined,
- // Tri-state provider persistence keyed on providerRuntimeCapability:
- // "capable" → persist: value if changed, omit if unchanged.
- // "locked" → clear: send null if provider was set, else omit.
- // "unknown" → omit always (never send null for a transient state).
- // llmProviderFieldVisible is for UX visibility only; not used here.
+ // Tri-state provider persistence keyed on providerRuntimeCapability
+ // (see the classification comment above for the capable/locked/unknown
+ // contract). llmProviderFieldVisible is UX visibility only; not used here.
provider:
linkedPersona != null
? undefined
@@ -1128,6 +1126,8 @@ export function AgentInstanceEditDialog({
) : null}
+
+
setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx
new file mode 100644
index 0000000000..a06f17ac11
--- /dev/null
+++ b/desktop/src/features/agents/ui/EffortPickerField.tsx
@@ -0,0 +1,81 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks";
+import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents";
+import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types";
+import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions";
+import {
+ effortPickerState,
+ effortSelectionToPersistedValue,
+} from "./effortPicker";
+import { PersonaDropdownField } from "./PersonaDropdownField";
+
+/**
+ * Thinking-effort write control for the edit dialog (B5, v4 direct-write).
+ *
+ * Local-only by construction: the write calls `persistAgentEffortLevel`, which
+ * the Rust command rejects for non-local backends (remote effort is set at
+ * deploy time via `policy_env`). So the control renders only for a local
+ * backend AND once the adapter has advertised a `thought_level` configId
+ * (discovered from the running session — absent pre-first-session and for
+ * runtimes/models without effort support). The read-only configured-vs-running
+ * two-facts display lives in `AgentConfigPanel`; this is the write control.
+ *
+ * Direct-write: each selection persists immediately and invalidates the config
+ * surface so the panel's canonical tier reflects the new next-spawn value.
+ */
+export function EffortPickerField({
+ agent,
+ config,
+}: {
+ agent: ManagedAgent;
+ config: RuntimeConfigSurface | undefined;
+}) {
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: (level: string | null) =>
+ persistAgentEffortLevel(agent.pubkey, level),
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: agentConfigSurfaceQueryKey(agent.pubkey),
+ }),
+ });
+ const { visible, options, selectValue } = effortPickerState({
+ backend: agent.backend,
+ effortConfigId: config?.effortConfigId,
+ effortOptions: config?.effortOptions,
+ currentEffort: config?.normalized.thinkingEffort?.value ?? null,
+ });
+
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+
+ Thinking effort
+ Optional
+
+
+ mutation.mutate(effortSelectionToPersistedValue(value))
+ }
+ options={options}
+ placeholder="Adapter default"
+ value={selectValue}
+ />
+
+ Applied at the next session start.
+
+ {mutation.error instanceof Error ? (
+ {mutation.error.message}
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/McpServersSection.test.mjs b/desktop/src/features/agents/ui/McpServersSection.test.mjs
new file mode 100644
index 0000000000..526acbca5e
--- /dev/null
+++ b/desktop/src/features/agents/ui/McpServersSection.test.mjs
@@ -0,0 +1,90 @@
+/**
+ * #3493 provenance: the MCP servers section must attribute its entries to the
+ * ACTUAL config file the reader read — which, under a custom CLAUDE_CONFIG_DIR,
+ * is the isolated `/.claude.json`, not the default `~/.claude.json`.
+ *
+ * Before this fix `mcpConfigFilePath` was carried on the DTO but no component
+ * consumed it, so the panel listed the correct servers with no file
+ * attribution at all. These pin the rendered contract.
+ */
+
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+import {
+ McpServersSection,
+ mcpConfigFileCaption,
+} from "./McpServersSection.tsx";
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+const CLAUDE_MCP = [{ name: "sentinel", kind: "stdio", enabled: true }];
+
+test("mcpConfigFileCaption_customPath_returnsFileAttribution", () => {
+ assert.equal(
+ mcpConfigFileCaption("/tmp/iso/.claude.json"),
+ "From config file (/tmp/iso/.claude.json)",
+ );
+});
+
+test("mcpConfigFileCaption_nullPath_returnsNull", () => {
+ assert.equal(mcpConfigFileCaption(null), null);
+ assert.equal(mcpConfigFileCaption(undefined), null);
+});
+
+test("McpServersSection_customConfigDir_rendersIsolatedFilePath", async () => {
+ const { render } = await import("@testing-library/react");
+ const React = await import("react");
+
+ const { container } = render(
+ React.createElement(McpServersSection, {
+ extensions: CLAUDE_MCP,
+ mcpConfigFilePath: "/tmp/iso/.claude.json",
+ runtimeId: "claude",
+ variant: "compact",
+ }),
+ );
+
+ assert.match(container.textContent, /sentinel/);
+ assert.match(
+ container.textContent,
+ /From config file \(\/tmp\/iso\/\.claude\.json\)/,
+ );
+});
+
+test("McpServersSection_noConfigPath_omitsFileAttribution", async () => {
+ const { render } = await import("@testing-library/react");
+ const React = await import("react");
+
+ const { container } = render(
+ React.createElement(McpServersSection, {
+ extensions: CLAUDE_MCP,
+ mcpConfigFilePath: null,
+ runtimeId: "claude",
+ variant: "compact",
+ }),
+ );
+
+ assert.match(container.textContent, /sentinel/);
+ assert.doesNotMatch(container.textContent, /From config file/);
+});
diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx
index f92f77a392..db3de3b200 100644
--- a/desktop/src/features/agents/ui/McpServersSection.tsx
+++ b/desktop/src/features/agents/ui/McpServersSection.tsx
@@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn";
type McpServersSectionProps = {
extensions: ExtensionEntry[];
runtimeId: string | null;
+ mcpConfigFilePath?: string | null;
variant?: "compact" | "profile";
buzzAgentSlot?: React.ReactNode;
};
@@ -19,9 +20,19 @@ export function shouldRenderMcpServers(
return runtimeId === "buzz-agent" || extensions.length > 0;
}
+// #3493: the servers are read from the isolated `.claude.json` under a custom
+// `CLAUDE_CONFIG_DIR`. Attribute them to that actual file so the panel never
+// implies the default `~/.claude.json` when isolation is in effect.
+export function mcpConfigFileCaption(
+ mcpConfigFilePath: string | null | undefined,
+): string | null {
+ return mcpConfigFilePath ? `From config file (${mcpConfigFilePath})` : null;
+}
+
export function McpServersSection({
buzzAgentSlot,
extensions,
+ mcpConfigFilePath,
runtimeId,
variant = "compact",
}: McpServersSectionProps) {
@@ -31,6 +42,8 @@ export function McpServersSection({
return null;
}
+ const fileCaption = mcpConfigFileCaption(mcpConfigFilePath);
+
return (
)}
+
+ {extensions.length > 0 && fileCaption ? (
+
+ {fileCaption}
+
+ ) : null}
);
}
diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx
index 863cd85119..0bc6f9646a 100644
--- a/desktop/src/features/agents/ui/ModelPicker.tsx
+++ b/desktop/src/features/agents/ui/ModelPicker.tsx
@@ -109,26 +109,39 @@ export function ModelPicker({
}, [configSurface]);
// Send a live `switch_model` frame to each channel the agent is working in
- // and wait for the harness to acknowledge. Any single `unsupported_model`
- // result rejects the whole pick immediately; all other statuses must arrive
- // from every channel before resolving success.
+ // and wait for the harness to acknowledge. A single `unsupported_model`
+ // (model unavailable) or `failure` (adapter refused) result rejects the whole
+ // pick immediately. The busy-path `sent` ack is provisional (the adapter
+ // isn't consulted until the requeued session); success is confirmed only by a
+ // real positive terminal frame from every channel, and if none arrives before
+ // the timeout the pick resolves `"pending"` (accepted, apply deferred).
const sendLiveSwitch = React.useCallback(
(modelId: string) => {
const channelIds = activeTurns.map((turn) => turn.channelId);
+ // Opaque per-pick correlator. The harness echoes it on the immediate ack
+ // and the late terminal frame, so a five-minute reconnect replay of an
+ // earlier pick's result cannot settle this one.
+ const requestId = crypto.randomUUID();
return awaitLiveSwitchOutcome({
- channelCount: channelIds.length,
- modelId,
+ requestId,
+ channelIds,
subscribe: (listener) =>
subscribeControlResults(agent.pubkey, listener),
sendSwitches: async () => {
await Promise.all(
channelIds.map((channelId) =>
- switchManagedAgentModel(agent.pubkey, channelId, modelId),
+ switchManagedAgentModel(
+ agent.pubkey,
+ channelId,
+ modelId,
+ requestId,
+ ),
),
);
},
- // No reply in time: treat as sent. The override still rides the
- // requeued/next session; we just can't confirm synchronously.
+ // No positive terminal in time: resolve `"pending"`. The override still
+ // rides the requeued/next session; we just can't confirm synchronously,
+ // and must not claim a success that hasn't happened.
scheduleTimeout: (onTimeout) => {
const timeout = window.setTimeout(onTimeout, 8_000);
return () => window.clearTimeout(timeout);
@@ -148,6 +161,31 @@ export function ModelPicker({
toast.error("That model isn't available for this agent.");
return;
}
+ if (outcome === "failed") {
+ toast.error(
+ "Couldn't switch models — the agent kept its current model.",
+ );
+ return;
+ }
+ if (outcome === "not_delivered") {
+ // The switch never reached a session: the turn was already ending, or
+ // no active turn remained by the time the harness received it. Nothing
+ // was applied and nothing rides a later session — tell the truth.
+ toast.error(
+ "Couldn't switch models — the agent wasn't running a turn to switch.",
+ );
+ return;
+ }
+ if (outcome === "pending") {
+ // The switch was accepted but its apply is deferred to the next
+ // session (the agent is mid-turn) and didn't confirm before the
+ // fallback timeout. Tell the truth instead of claiming success.
+ toast.info(
+ "Model switch pending — applies when the current turn finishes.",
+ );
+ onModelChanged?.();
+ return;
+ }
toast.success("Model switched for this session.");
onModelChanged?.();
return;
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx
index 5cf4f9ea3b..13eae7971b 100644
--- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx
+++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx
@@ -391,7 +391,10 @@ export function SnapshotShareDialog({
toast.success(`Sent a copy of ${displayName}`);
onOpenChange(false);
} else if (sent === false) {
- toast.error(`Couldn’t send ${itemLabel}. Try again.`);
+ toast.error(
+ snapshotSendController.getCurrentError() ??
+ `Couldn’t send ${itemLabel}. Try again.`,
+ );
}
}
diff --git a/desktop/src/features/agents/ui/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs
new file mode 100644
index 0000000000..28c22ec9d9
--- /dev/null
+++ b/desktop/src/features/agents/ui/effortPicker.test.mjs
@@ -0,0 +1,110 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ EFFORT_DEFAULT_DROPDOWN_VALUE,
+ effortPickerState,
+ effortSelectionToPersistedValue,
+} from "./effortPicker.ts";
+
+const localBackend = { type: "local" };
+const providerBackend = { type: "provider", id: "openai", config: {} };
+const options = [
+ { value: "low", displayName: "Low" },
+ { value: "high", displayName: "High" },
+];
+
+test("effort picker renders for a local backend with a discovered configId", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: null,
+ });
+ assert.equal(state.visible, true);
+});
+
+test("effort picker is hidden for a provider backend even when a configId exists", () => {
+ const state = effortPickerState({
+ backend: providerBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: "high",
+ });
+ assert.equal(state.visible, false);
+});
+
+test("effort picker is hidden for a local backend without a discovered configId", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: undefined,
+ effortOptions: undefined,
+ currentEffort: null,
+ });
+ assert.equal(state.visible, false);
+});
+
+test("options lead with the adapter-default sentinel then adapter values", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: null,
+ });
+ assert.deepEqual(state.options, [
+ { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
+ { label: "Low", value: "low" },
+ { label: "High", value: "high" },
+ ]);
+});
+
+test("option label falls back to the raw value when displayName is absent", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: [{ value: "medium" }],
+ currentEffort: null,
+ });
+ assert.deepEqual(state.options[1], { label: "medium", value: "medium" });
+});
+
+test("current effort preselects the matching option", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: "high",
+ });
+ assert.equal(state.selectValue, "high");
+});
+
+test("an unknown current effort falls back to the adapter-default sentinel", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: "extreme",
+ });
+ assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE);
+});
+
+test("a null current effort selects the adapter-default sentinel", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortConfigId: "thought_level",
+ effortOptions: options,
+ currentEffort: null,
+ });
+ assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE);
+});
+
+test("the sentinel selection persists as null (clear to adapter default)", () => {
+ assert.equal(
+ effortSelectionToPersistedValue(EFFORT_DEFAULT_DROPDOWN_VALUE),
+ null,
+ );
+});
+
+test("a concrete selection persists as its explicit effort level", () => {
+ assert.equal(effortSelectionToPersistedValue("high"), "high");
+});
diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts
new file mode 100644
index 0000000000..515355e4ad
--- /dev/null
+++ b/desktop/src/features/agents/ui/effortPicker.ts
@@ -0,0 +1,71 @@
+import type {
+ AcpConfigOptionValue,
+ ManagedAgentBackend,
+} from "@/shared/api/types";
+import type { PersonaDropdownOption } from "./agentConfigOptions";
+
+/**
+ * Sentinel dropdown value for "no explicit effort" — reverts the agent to the
+ * adapter default at the next spawn. Distinct from any adapter option value.
+ */
+export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__";
+
+/**
+ * Pure gating + option compute for the effort write control in the edit dialog.
+ *
+ * The picker is a LOCAL-only, direct-write control: it calls
+ * `persistAgentEffortLevel`, which the Rust command rejects for non-local
+ * backends (remote effort is set at deploy time via `policy_env`). So the UI
+ * must not offer it for a provider backend, and there's nothing to pick until
+ * the adapter has advertised a `thought_level` config option (discovered from
+ * the running session — `effortConfigId` is absent pre-first-session and for
+ * runtimes/models that don't support effort).
+ *
+ * `visible` is the single gate the dialog renders on: local backend AND a
+ * discovered `effortConfigId`.
+ */
+export function effortPickerState({
+ backend,
+ effortConfigId,
+ effortOptions,
+ currentEffort,
+}: {
+ backend: ManagedAgentBackend;
+ effortConfigId: string | undefined;
+ effortOptions: readonly AcpConfigOptionValue[] | undefined;
+ currentEffort: string | null;
+}): {
+ visible: boolean;
+ options: PersonaDropdownOption[];
+ selectValue: string;
+} {
+ const visible = backend.type === "local" && effortConfigId !== undefined;
+
+ const options: PersonaDropdownOption[] = [
+ { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
+ ...(effortOptions ?? []).map((option) => ({
+ label: option.displayName ?? option.value,
+ value: option.value,
+ })),
+ ];
+
+ // Preselect the currently-configured effort when it maps to a known option;
+ // otherwise fall back to the adapter-default sentinel (also the null case).
+ const trimmed = currentEffort?.trim() ?? "";
+ const selectValue =
+ trimmed.length > 0 &&
+ (effortOptions ?? []).some((option) => option.value === trimmed)
+ ? trimmed
+ : EFFORT_DEFAULT_DROPDOWN_VALUE;
+
+ return { visible, options, selectValue };
+}
+
+/**
+ * Map a dropdown selection back to the value persisted via
+ * `persistAgentEffortLevel`: the sentinel clears effort (null → adapter
+ * default), any other value is the explicit effort level.
+ */
+export function effortSelectionToPersistedValue(value: string): string | null {
+ return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value;
+}
diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts
index e14481b750..d7e4daca2c 100644
--- a/desktop/src/features/agents/ui/useSnapshotSendController.ts
+++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts
@@ -350,6 +350,12 @@ export type UseSnapshotSendControllerResult = {
/** Relay moderation identity to exclude from the people picker. */
relaySelfPubkey: string | null;
state: SnapshotSendState;
+ /**
+ * Read the latest error synchronously — right after `beginSend` resolves the
+ * render-captured `state.error` is stale until the next commit, so callers
+ * that toast on failure must read through here.
+ */
+ getCurrentError: () => string | null;
/**
* Execute destination creation plus prepare → encode → upload → send behind
* one concurrency guard. A second call while the first is in flight returns
@@ -390,6 +396,15 @@ export function useSnapshotSendController(
error: null,
});
+ // Mirror `state` into a ref so callers can read the latest error
+ // synchronously right after `beginSend` resolves — the render-captured
+ // `state` in their closure is stale until the next render commits.
+ const stateRef = React.useRef(state);
+ const commitState = React.useCallback((next: SnapshotSendState) => {
+ stateRef.current = next;
+ setState(next);
+ }, []);
+
// Single-concurrency guard covering the full encode → upload → send action.
// Stored in a ref so it survives re-renders without triggering effects.
const guardRef = React.useRef(createSendGuard());
@@ -417,7 +432,7 @@ export function useSnapshotSendController(
checkEligibilityFn: () => checkSendEligibility(queryClient, channelId),
uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename),
sendFn: (args) => sendMutation.mutateAsync(args),
- setStateFn: setState,
+ setStateFn: commitState,
buildMessageFn: (descriptor) => {
const message = buildOutgoingMessage("", [descriptor]);
return attachmentLabel?.trim()
@@ -430,15 +445,15 @@ export function useSnapshotSendController(
: message;
},
}),
- setState,
+ commitState,
);
}
const reset = React.useCallback(() => {
if (!guardRef.current.inFlight) {
- setState({ phase: "idle", error: null });
+ commitState({ phase: "idle", error: null });
}
- }, []);
+ }, [commitState]);
return {
isDmSafetyReady:
@@ -447,6 +462,7 @@ export function useSnapshotSendController(
relaySelfQuery.status === "success"),
relaySelfPubkey: relaySelfQuery.data ?? null,
state,
+ getCurrentError: () => stateRef.current.error,
beginSend,
reset,
};
diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx
index 69e511fb40..512ee6899f 100644
--- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx
+++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx
@@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({
-
+
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx
index d685a96103..cfa84f02e3 100644
--- a/desktop/src/features/channels/ui/BotActivityBar.tsx
+++ b/desktop/src/features/channels/ui/BotActivityBar.tsx
@@ -10,7 +10,12 @@ import {
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
-import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/shared/ui/popover";
import { Shimmer } from "@/shared/ui/Shimmer";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -26,7 +31,6 @@ type BotActivityBarProps = {
variant?: "toolbar" | "inline";
};
-const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
const HEADLINE_ROTATION_MS = 2200;
@@ -106,7 +110,7 @@ export function BotActivityComposerAction({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
- }, HOVER_OPEN_DELAY_MS);
+ }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer]);
const closeWithDelay = React.useCallback(() => {
diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
index cd530e6908..4d6f2d258b 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -39,6 +39,12 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons";
import { EditCommunityDialog } from "./EditCommunityDialog";
+// Community actions is a responsive navigation submenu, not an informational
+// disclosure. Keep its short hover dwell explicit rather than inheriting the
+// shared 500 ms Popover delay intended to prevent incidental inspection UI.
+const PROFILE_MENU_HOVER_OPEN_DELAY_MS = 80;
+const PROFILE_MENU_HOVER_CLOSE_DELAY_MS = 160;
+
const CONNECTION_STATE_LABEL: Record = {
idle: "Not connected",
connecting: "Connecting…",
@@ -128,7 +134,9 @@ export function CommunitySwitcher({
clearProfileMenuHoverTimer();
profileMenuHoverTimer.current = window.setTimeout(
() => setDropdownOpen(nextOpen),
- nextOpen ? 80 : 160,
+ nextOpen
+ ? PROFILE_MENU_HOVER_OPEN_DELAY_MS
+ : PROFILE_MENU_HOVER_CLOSE_DELAY_MS,
);
}
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx
index 9bb593087d..4b64ad10e0 100644
--- a/desktop/src/features/home/ui/InboxDetailPane.tsx
+++ b/desktop/src/features/home/ui/InboxDetailPane.tsx
@@ -622,7 +622,7 @@ function InboxMessageDetailPane({
-
+
{canOpenChannel && contextChannelId ? (
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index b83a19a167..f2d4421081 100644
--- a/desktop/src/features/home/ui/InboxListPane.tsx
+++ b/desktop/src/features/home/ui/InboxListPane.tsx
@@ -433,14 +433,14 @@ export function InboxListPane({
{timestampLabel}
@@ -138,7 +139,7 @@ export function InboxMessageRow({
) : null}
-
+
{hoverTimestampLabel}
@@ -207,14 +208,20 @@ export function InboxMessageRow({
{isContinuation ? null : (
-
+
-
+
{message.authorLabel}
@@ -240,10 +247,13 @@ export function InboxMessageRow({
)}
-
+
{children}
@@ -93,7 +94,7 @@ export function MessageAuthorText({
return (
{
if (reaction.users.length === 0) return;
clearTimers();
- openTimeout.current = setTimeout(() => setOpen(true), 200);
+ openTimeout.current = setTimeout(
+ () => setOpen(true),
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ );
}, [reaction.users.length, clearTimers]);
const scheduleClose = React.useCallback(() => {
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index de496836c1..fd5be7d9a8 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -295,7 +295,7 @@ export const MessageRow = React.memo(
message.body,
message.tags,
);
- const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
+ const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
@@ -411,7 +411,7 @@ export const MessageRow = React.memo(
{authorNode}
@@ -868,7 +868,7 @@ export const MessageRow = React.memo(
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
- "py-1",
+ "py-conversation-row",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
@@ -888,17 +888,21 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
{avatarGutterNode}
-
+
{headerNode}
-
{messageBodyNode}
+
+ {messageBodyNode}
+
>
) : (
<>
{avatarGutterNode}
-
+
{headerNode}
-
{messageBodyNode}
+
+ {messageBodyNode}
+
>
)}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index f8c5395b17..df9af09e67 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -692,7 +692,7 @@ const MessageTimelineBase = React.forwardRef<
) : null;
return (
-
+
{showUnreadPill ? (
= {
@@ -244,7 +248,7 @@ export function UserProfilePopover({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
- }, HOVER_OPEN_DELAY_MS);
+ }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer, enableHoverPopover]);
const handleMouseLeave = React.useCallback(() => {
diff --git a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
index 6e3d160453..ea8a1dc97e 100644
--- a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
+++ b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
@@ -1,5 +1,6 @@
+import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { ChevronDown } from "lucide-react";
+import { ChevronDown, Eye } from "lucide-react";
import {
setThreadViewMode,
useThreadViewMode,
@@ -14,6 +15,18 @@ import {
type LinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { isLinuxPlatform } from "@/shared/lib/platform";
+import {
+ previewConversationDensity,
+ setConversationDensity,
+ useConversationDensity,
+ type ConversationDensity,
+} from "@/shared/lib/conversationDensityPreference";
+import {
+ previewFontSize,
+ setFontSize,
+ useFontSize,
+ type FontSize,
+} from "@/shared/lib/fontSizePreference";
import {
ACCENT_COLORS,
DEFAULT_GLASS_OPACITY,
@@ -22,6 +35,7 @@ import {
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
+
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -32,6 +46,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionRow } from "./SettingsOptionGroup";
+import { SegmentedControl } from "@/shared/ui/segmented-control";
/** Buzz navigation can use either its production tint or a stronger tab. */
export function ProminentActiveTabSetting() {
@@ -71,15 +86,177 @@ const LINK_PREVIEW_STYLE_OPTIONS: {
{
value: "compact",
label: "Compact",
- description: "Show links as compact horizontal cards",
+ description: "Small cards with a thumbnail",
},
{
value: "rich",
label: "Rich",
- description: "Unfurl links with larger images and descriptions",
+ description: "Large previews with images and descriptions",
},
];
+const CONVERSATION_DENSITY_OPTIONS: readonly {
+ value: ConversationDensity;
+ label: string;
+}[] = [
+ {
+ value: "compact",
+ label: "Compact",
+ },
+ {
+ value: "comfortable",
+ label: "Comfy",
+ },
+ {
+ value: "spacious",
+ label: "Spacious",
+ },
+];
+
+const FONT_SIZE_OPTIONS: readonly {
+ value: FontSize;
+ label: string;
+}[] = [
+ {
+ value: "smaller",
+ label: "Smaller",
+ },
+ {
+ value: "default",
+ label: "Default",
+ },
+ {
+ value: "larger",
+ label: "Larger",
+ },
+];
+
+function ConversationDensityPreviewMessage({
+ avatar,
+ author,
+ children,
+ timestamp,
+}: {
+ avatar: string;
+ author: string;
+ children: ReactNode;
+ timestamp: string;
+}) {
+ return (
+
+
+ {avatar}
+
+
+
+
+ {author}
+
+
+ {timestamp}
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function ConversationPreview() {
+ return (
+
+
+
+
+ Preview
+
+
+
+ The revised conversation layout is ready to review.
+
+
+
+ I added a longer message so you can compare line height and text
+ spacing.
+
+
+ The same rhythm carries through channels, threads, DMs, and Inbox.
+
+
+
+
+
+ );
+}
+
+/** App-wide type sizing and conversation-specific spacing controls. */
+export function ConversationDisplaySettings() {
+ const density = useConversationDensity();
+ const fontSize = useFontSize();
+
+ return (
+
+
+
+
Font size
+
+ Applies across conversations and interface text
+
+
+
+
+
+
+
Conversation density
+
+ Spacing in conversations and Markdown content across Buzz
+
+
+
+
+
+
+ );
+}
+
export function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const activeOption =
@@ -150,7 +327,7 @@ const THREAD_VIEW_MODE_OPTIONS: {
{
value: "focus",
label: "Focus",
- description: "Threads open over the channel, full width",
+ description: "Threads open over the channel",
},
{
value: "split",
diff --git a/desktop/src/features/settings/ui/SettingsOptionGroup.tsx b/desktop/src/features/settings/ui/SettingsOptionGroup.tsx
index 72b292c447..29638dd3b4 100644
--- a/desktop/src/features/settings/ui/SettingsOptionGroup.tsx
+++ b/desktop/src/features/settings/ui/SettingsOptionGroup.tsx
@@ -45,6 +45,7 @@ export function SettingsOptionGroup({
) : null}
[data-slot=segmented-control]]:w-full",
className,
)}
{...props}
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index b509a70e4e..6b00fc8f74 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -55,6 +55,7 @@ import {
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
import {
AccentPickerContent,
+ ConversationDisplaySettings,
GlassBackgroundSetting,
LinkPreviewStyleSetting,
ProminentActiveTabSetting,
@@ -74,6 +75,7 @@ import {
SettingsOptionGroupList,
SettingsOptionRow,
} from "./SettingsOptionGroup";
+import { SegmentedControl } from "@/shared/ui/segmented-control";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
@@ -674,39 +676,19 @@ function ThemeSettingsCard() {
Follow your system or choose a light or dark appearance.
-
- Color mode
- option.mode === selectedMode) * 100}%)`,
- width: "calc((100% - 4px) / 3)",
- }}
- />
- {APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => (
-
handleModeSelect(mode)}
- type="button"
- >
-
- {label}
-
- ))}
-
+
({
+ value: mode,
+ label,
+ Icon,
+ }))}
+ testId="appearance-color-mode-control"
+ value={selectedMode}
+ />
@@ -808,6 +790,7 @@ function ThemeSettingsCard() {
data-testid="appearance-preferences-card"
title="Preferences"
>
+
diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
index 1c86ac4c36..e6e24ce27b 100644
--- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
+++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
@@ -16,10 +16,14 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useNow } from "@/shared/lib/useNow";
import { Markdown } from "@/shared/ui/markdown";
-import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
-const HOVER_OPEN_DELAY_MS = 250;
const HOVER_CLOSE_DELAY_MS = 180;
const ACTIVITY_POPOVER_MOTION_STYLE = {
"--tw-enter-scale": "1",
@@ -310,7 +314,7 @@ export function ChannelActivityPopover({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
- }, HOVER_OPEN_DELAY_MS);
+ }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer, hasContent]);
const openImmediately = React.useCallback(() => {
if (!hasContent) return;
diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx
index 5394065b19..12cb2ea9d3 100644
--- a/desktop/src/features/sidebar/ui/CommunityRail.tsx
+++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx
@@ -374,7 +374,7 @@ export function CommunityRail({
return (
-
+
@@ -123,6 +126,8 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
+ initializeConversationDensityPreference();
+ initializeFontSizePreference();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts
index 677f0ffad4..70fb752939 100644
--- a/desktop/src/shared/api/agentControl.ts
+++ b/desktop/src/shared/api/agentControl.ts
@@ -17,15 +17,21 @@ export async function cancelManagedAgentTurn(
* the harness's cancel-switch-requeue path (busy turn) or invalidate-and-reapply
* (idle); the outcome arrives asynchronously as a `control_result` observer
* frame, not as the return value here. This is fire-and-forget on the send side.
+ *
+ * `requestId` is an opaque per-pick correlator the harness echoes back on both
+ * the immediate ack and the late terminal frame, so a reconnect replay of an
+ * earlier pick's result cannot settle this one.
*/
export async function switchManagedAgentModel(
pubkey: string,
channelId: string,
modelId: string,
+ requestId: string,
): Promise {
await sendAgentObserverControl(pubkey, {
type: "switch_model",
channelId,
modelId,
+ requestId,
});
}
diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts
index 2d76e0c3f7..6e100fe7b4 100644
--- a/desktop/src/shared/api/relayAuthPolicy.ts
+++ b/desktop/src/shared/api/relayAuthPolicy.ts
@@ -26,6 +26,28 @@ export type AuthOkDecision = "authenticated" | "retry" | "terminal";
export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3;
+export type RelayAuthRequest = {
+ pendingEventId: string;
+ resolve: () => void;
+ reject: (error: Error) => void;
+ timeout: number;
+};
+
+export function armRelayAuthentication(
+ timeoutMs: number,
+ setRequest: (request: RelayAuthRequest) => void,
+ onTimeout: (error: Error) => void,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = window.setTimeout(() => {
+ const error = new Error("Relay authentication timed out.");
+ onTimeout(error);
+ reject(error);
+ }, timeoutMs);
+ setRequest({ pendingEventId: "", resolve, reject, timeout });
+ });
+}
+
/** Tracks consecutive AUTH rejections across reconnect attempts. */
export class AuthOkTracker {
private consecutiveRejections = 0;
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 29b24f21d1..5e7f5c06e3 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -67,7 +67,12 @@ import {
STALL_IDLE_TIMEOUT_MS,
} from "@/shared/api/relayClientTimings";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
-import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
+import {
+ armRelayAuthentication,
+ AuthOkTracker,
+ type RelayAuthRequest,
+} from "@/shared/api/relayAuthPolicy";
+import { createRelayInboundBuffer } from "@/shared/api/relayInboundBuffer";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
export class RelayClient {
@@ -78,12 +83,7 @@ export class RelayClient {
private reconnectWaiters = new RelayReconnectWaiters();
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
private keepAliveRequested = false;
- private authRequest: {
- pendingEventId: string;
- resolve: () => void;
- reject: (error: Error) => void;
- timeout: number;
- } | null = null;
+ private authRequest: RelayAuthRequest | null = null;
private subscriptions = new Map();
private pendingEvents = new Map();
private eventBuffer: Array<{ subId: string; event: RelayEvent }> = [];
@@ -96,7 +96,6 @@ export class RelayClient {
private stabilityTimer: number | null = null;
private visibleChannelId: string | null = null;
private authOkTracker = new AuthOkTracker();
-
private terminal = false;
private connectionStateEmitter = new RelayConnectionStateEmitter("idle");
@@ -530,17 +529,19 @@ export class RelayClient {
this.connectionStateEmitter.set(
this.hasConnectedOnce ? "reconnecting" : "connecting",
);
-
const generation = ++this.connectionGeneration;
- this.onMessageChannel = new Channel((message) => {
- void this.handleWsMessage(message, generation).catch((error) => {
+ const inbound = createRelayInboundBuffer(
+ (message) => this.handleWsMessage(message, generation),
+ (error) => {
if (generation !== this.connectionGeneration) return;
this.resetConnection(
this.normalizeRelayError(error, "Relay connection errored."),
);
- });
- });
-
+ },
+ );
+ this.onMessageChannel = new Channel((message) =>
+ inbound.receive(message),
+ );
try {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
@@ -556,22 +557,21 @@ export class RelayClient {
}
this.wsId = wsId;
- await new Promise((resolve, reject) => {
- const timeout = window.setTimeout(() => {
- const error = new Error("Relay authentication timed out.");
+ const authentication = armRelayAuthentication(
+ AUTH_TIMEOUT_MS,
+ (request) => {
+ this.authRequest = request;
+ },
+ (error) => {
this.authRequest = null;
this.resetConnection(error);
- reject(error);
- }, AUTH_TIMEOUT_MS);
-
- this.authRequest = {
- pendingEventId: "",
- resolve,
- reject,
- timeout,
- };
- });
+ },
+ );
+ const drain = inbound.drain();
+ await Promise.race([drain, authentication, inbound.overflow]);
+ await drain;
+ await authentication;
this.stabilityTimer = window.setTimeout(() => {
this.stabilityTimer = null;
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
diff --git a/desktop/src/shared/api/relayInboundBuffer.test.mjs b/desktop/src/shared/api/relayInboundBuffer.test.mjs
new file mode 100644
index 0000000000..9add519d73
--- /dev/null
+++ b/desktop/src/shared/api/relayInboundBuffer.test.mjs
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ createRelayInboundBuffer,
+ MAX_PENDING_RELAY_FRAMES,
+} from "./relayInboundBuffer.ts";
+
+test("drains queued frames in order, including frames received during drain", async () => {
+ const handled = [];
+ let releaseFirst;
+ const firstBlocked = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ const inbound = createRelayInboundBuffer(async (message) => {
+ handled.push(message);
+ if (message === "first") await firstBlocked;
+ }, assert.fail);
+
+ inbound.receive("first");
+ const drain = inbound.drain();
+ inbound.receive("second");
+ releaseFirst();
+ await drain;
+ inbound.receive("live");
+ await new Promise((resolve) => setTimeout(resolve));
+
+ assert.deepEqual(handled, ["first", "second", "live"]);
+});
+
+test("rejects, resets, and stops accepting frames when the cap is exceeded", async () => {
+ let overflowError;
+ const inbound = createRelayInboundBuffer(
+ async () => {},
+ (error) => {
+ overflowError = error;
+ },
+ );
+ for (let i = 0; i < MAX_PENDING_RELAY_FRAMES + 1; i++) inbound.receive(i);
+
+ await assert.rejects(
+ inbound.overflow,
+ /Relay sent too many frames while connecting/,
+ );
+ assert.match(overflowError.message, /too many frames/);
+});
diff --git a/desktop/src/shared/api/relayInboundBuffer.ts b/desktop/src/shared/api/relayInboundBuffer.ts
new file mode 100644
index 0000000000..e4291e5ecc
--- /dev/null
+++ b/desktop/src/shared/api/relayInboundBuffer.ts
@@ -0,0 +1,36 @@
+export const MAX_PENDING_RELAY_FRAMES = 256;
+
+export function createRelayInboundBuffer(
+ handle: (message: unknown) => Promise,
+ onError: (error: unknown) => void,
+) {
+ let pending: unknown[] | null | undefined = [];
+ let rejectOverflow = (_error: Error) => {};
+ const overflow = new Promise((_resolve, reject) => {
+ rejectOverflow = reject;
+ });
+ void overflow.catch(() => {});
+
+ return {
+ overflow,
+ receive(message: unknown) {
+ if (pending === null) {
+ void handle(message).catch(onError);
+ return;
+ }
+ if (pending === undefined) return;
+ if (pending.length >= MAX_PENDING_RELAY_FRAMES) {
+ pending = undefined;
+ const error = new Error("Relay sent too many frames while connecting.");
+ rejectOverflow(error);
+ onError(error);
+ return;
+ }
+ pending.push(message);
+ },
+ async drain() {
+ while (pending?.length) await handle(pending.shift());
+ if (pending) pending = null;
+ },
+ };
+}
diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts
index c74b099f88..88bce2ec27 100644
--- a/desktop/src/shared/api/tauriManagedAgents.ts
+++ b/desktop/src/shared/api/tauriManagedAgents.ts
@@ -50,6 +50,21 @@ export async function setManagedAgentAutoRestart(
return fromRawManagedAgent(response);
}
+/**
+ * B5: persist the canonical startup effort for a local managed agent. Applied
+ * as `BUZZ_ACP_EFFORT_LEVEL` at the next spawn. Pass `null` to clear (reverts
+ * to the adapter default). Rejects non-local agents.
+ */
+export async function persistAgentEffortLevel(
+ pubkey: string,
+ effortLevel: string | null,
+): Promise {
+ return invokeTauri("persist_agent_effort_level", {
+ pubkey,
+ effortLevel,
+ });
+}
+
export async function listManagedAgentRuntimes(): Promise<
ManagedAgentRuntimeStatus[]
> {
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index dcf6d2e8bc..41c63f7be9 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -339,18 +339,9 @@ export type ManagedAgent = {
modelSource: "definition" | "global" | "instance_legacy" | null;
/** LLM inference provider, from the agent's pinned record snapshot. */
provider: string | null;
- /**
- * `true` when the linked persona has been edited since this agent was
- * created — the running agent uses the older pinned snapshot. Surface a
- * "out of date" marker and prompt the user to delete + respawn to update.
- * Always `false` for non-persona agents and for orphaned agents.
- */
+ /** True when the linked persona has been edited since this agent was created. */
personaOutOfDate: boolean;
- /**
- * `true` when the agent's linked persona no longer exists. Distinct from
- * out-of-date: there is no current persona to respawn into, so do not prompt
- * a respawn — the pinned snapshot is all the config that remains.
- */
+ /** True when this agent's linked persona no longer exists. */
personaOrphaned: boolean;
/**
* `true` when the running process was spawned with a config that no longer
@@ -461,23 +452,23 @@ export type CancelManagedAgentTurnResult = {
status: "sent" | "no_active_turn";
};
-/**
- * Outcome of a live `switch_model` control frame, surfaced asynchronously via
- * the agent's `control_result` observer frame. Busy path: `sent` (cancel +
- * requeue on the new model) or `turn_ending` (oneshot already consumed this
- * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`.
- */
+/** Outcome of a live `switch_model` control frame; `failure` lands late. */
export type SwitchManagedAgentModelStatus =
| "sent"
| "turn_ending"
| "switched"
| "unsupported_model"
- | "no_active_turn";
+ | "no_active_turn"
+ | "failure";
export type ControlResultFrame = {
type: "cancel_turn" | "switch_model";
status: string;
modelId?: string;
+ /** Opaque per-pick id echoed from the request; correlates late frames. */
+ requestId?: string;
+ /** Buzz channel UUID from the observer envelope; disambiguates channels. */
+ channelId?: string | null;
};
export type GitBashPrerequisite = {
@@ -657,6 +648,9 @@ export type ConfigSourceReport = {
export type ExtensionEntry = { name: string; kind: string; enabled: boolean };
+/** B5/I-7: a single adapter-advertised value for an ACP config option. */
+export type AcpConfigOptionValue = { value: string; displayName?: string };
+
export type NormalizedConfig = {
model: NormalizedField | null;
provider: NormalizedField | null;
@@ -675,6 +669,12 @@ export type RuntimeConfigSurface = {
advanced: ConfigField[];
extensions: ExtensionEntry[];
sources: ConfigSourceReport;
+ /** #3493: `true` when the surface was read from a user-set `CLAUDE_CONFIG_DIR` — drives the Keychain caveat note in the panel. */
+ claudeConfigDirCustom?: boolean;
+ /** B5: the adapter-advertised `thought_level` configId, discovered from the running session. Present only for claude after the first session. Drives the effort picker. */
+ effortConfigId?: string;
+ /** B5/I-7: adapter-advertised option values for the `thought_level` option — the picker renders these instead of hardcoded values. */
+ effortOptions?: AcpConfigOptionValue[];
};
export type UpdateManagedAgentInput = {
diff --git a/desktop/src/shared/lib/cn.ts b/desktop/src/shared/lib/cn.ts
index a5ef193506..79fe7d2897 100644
--- a/desktop/src/shared/lib/cn.ts
+++ b/desktop/src/shared/lib/cn.ts
@@ -1,6 +1,18 @@
import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { extendTailwindMerge } from "tailwind-merge";
+
+const mergeClassNames = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ "font-size": [
+ {
+ text: ["message", "message-timestamp"],
+ },
+ ],
+ },
+ },
+});
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
+ return mergeClassNames(clsx(inputs));
}
diff --git a/desktop/src/shared/lib/conversationDensityPreference.test.mjs b/desktop/src/shared/lib/conversationDensityPreference.test.mjs
new file mode 100644
index 0000000000..2b5391f927
--- /dev/null
+++ b/desktop/src/shared/lib/conversationDensityPreference.test.mjs
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+const values = new Map();
+const attributes = new Map();
+const windowListeners = new Map();
+
+globalThis.window = {
+ addEventListener: (type, listener) => windowListeners.set(type, listener),
+};
+globalThis.localStorage = {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, String(value)),
+};
+globalThis.document = {
+ documentElement: {
+ setAttribute: (name, value) => attributes.set(name, value),
+ },
+};
+
+const preference = await import("./conversationDensityPreference.ts");
+
+test("defaults invalid and missing conversation densities to comfortable", () => {
+ assert.equal(preference.parseConversationDensity(null), "comfortable");
+ assert.equal(preference.parseConversationDensity("dense"), "comfortable");
+ assert.equal(preference.parseConversationDensity("compact"), "compact");
+ assert.equal(
+ preference.parseConversationDensity("comfortable"),
+ "comfortable",
+ );
+ assert.equal(preference.parseConversationDensity("spacious"), "spacious");
+});
+
+test("persists and applies the selected conversation density", () => {
+ preference.setConversationDensity("compact");
+ assert.equal(preference.getConversationDensity(), "compact");
+ assert.equal(
+ values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
+ "compact",
+ );
+ assert.equal(attributes.get("data-conversation-density"), "compact");
+});
+
+test("previews a density without changing the saved preference", () => {
+ preference.setConversationDensity("compact");
+ preference.previewConversationDensity("spacious");
+ assert.equal(preference.getConversationDensity(), "compact");
+ assert.equal(
+ values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
+ "compact",
+ );
+ assert.equal(attributes.get("data-conversation-density"), "spacious");
+
+ preference.previewConversationDensity(null);
+ assert.equal(attributes.get("data-conversation-density"), "compact");
+});
+
+test("initializes from the persisted conversation density", () => {
+ values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "spacious");
+ preference.initializeConversationDensityPreference();
+ assert.equal(preference.getConversationDensity(), "spacious");
+ assert.equal(attributes.get("data-conversation-density"), "spacious");
+});
+
+test("applies conversation density changes from another window", () => {
+ values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "compact");
+ windowListeners.get("storage")({
+ key: preference.CONVERSATION_DENSITY_STORAGE_KEY,
+ });
+ assert.equal(preference.getConversationDensity(), "compact");
+ assert.equal(attributes.get("data-conversation-density"), "compact");
+});
+
+test("returns to comfortable when another window clears storage", () => {
+ preference.setConversationDensity("spacious");
+ values.clear();
+ windowListeners.get("storage")({ key: null });
+ assert.equal(preference.getConversationDensity(), "comfortable");
+ assert.equal(attributes.get("data-conversation-density"), "comfortable");
+});
diff --git a/desktop/src/shared/lib/conversationDensityPreference.ts b/desktop/src/shared/lib/conversationDensityPreference.ts
new file mode 100644
index 0000000000..309a6c8d0e
--- /dev/null
+++ b/desktop/src/shared/lib/conversationDensityPreference.ts
@@ -0,0 +1,101 @@
+import * as React from "react";
+
+/** Device-level spacing used across conversation surfaces. */
+export type ConversationDensity = "compact" | "comfortable" | "spacious";
+
+export const CONVERSATION_DENSITY_STORAGE_KEY =
+ "buzz.appearance.conversationDensity";
+export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable";
+
+const listeners = new Set<() => void>();
+let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY;
+let listeningForStorageChanges = false;
+
+export function parseConversationDensity(
+ value: string | null | undefined,
+): ConversationDensity {
+ return value === "compact" || value === "comfortable" || value === "spacious"
+ ? value
+ : DEFAULT_CONVERSATION_DENSITY;
+}
+
+function readStoredConversationDensity(): ConversationDensity {
+ try {
+ return parseConversationDensity(
+ globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY),
+ );
+ } catch {
+ return DEFAULT_CONVERSATION_DENSITY;
+ }
+}
+
+function applyConversationDensity(density: ConversationDensity): void {
+ globalThis.document?.documentElement?.setAttribute(
+ "data-conversation-density",
+ density,
+ );
+}
+
+function notifyListeners(): void {
+ for (const listener of listeners) listener();
+}
+
+function applyStoredConversationDensity(): void {
+ const nextDensity = readStoredConversationDensity();
+ const changed = nextDensity !== conversationDensity;
+ conversationDensity = nextDensity;
+ applyConversationDensity(nextDensity);
+ if (changed) notifyListeners();
+}
+
+function listenForStorageChanges(): void {
+ if (listeningForStorageChanges || !globalThis.window?.addEventListener)
+ return;
+ globalThis.window.addEventListener("storage", (event) => {
+ if (event.key === CONVERSATION_DENSITY_STORAGE_KEY || event.key === null) {
+ applyStoredConversationDensity();
+ }
+ });
+ listeningForStorageChanges = true;
+}
+
+/** Apply the persisted preference before React renders to avoid a layout jump. */
+export function initializeConversationDensityPreference(): void {
+ applyStoredConversationDensity();
+ listenForStorageChanges();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function getConversationDensity(): ConversationDensity {
+ return conversationDensity;
+}
+
+export function setConversationDensity(density: ConversationDensity): void {
+ conversationDensity = density;
+ applyConversationDensity(density);
+ try {
+ globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density);
+ } catch {
+ // Persistence is best-effort; the live preference still applies.
+ }
+ notifyListeners();
+}
+
+/** Temporarily apply a density without changing the saved preference. */
+export function previewConversationDensity(
+ density: ConversationDensity | null,
+): void {
+ applyConversationDensity(density ?? conversationDensity);
+}
+
+export function useConversationDensity(): ConversationDensity {
+ return React.useSyncExternalStore(
+ subscribe,
+ getConversationDensity,
+ () => DEFAULT_CONVERSATION_DENSITY,
+ );
+}
diff --git a/desktop/src/shared/lib/fontSizePreference.test.mjs b/desktop/src/shared/lib/fontSizePreference.test.mjs
new file mode 100644
index 0000000000..217a3e5a00
--- /dev/null
+++ b/desktop/src/shared/lib/fontSizePreference.test.mjs
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import config from "../../../tailwind.config.js";
+
+const values = new Map();
+const attributes = new Map();
+const styleValues = new Map();
+const windowListeners = new Map();
+const style = {
+ setProperty: (name, value) => styleValues.set(name, value),
+};
+
+globalThis.window = {
+ addEventListener: (type, listener) => windowListeners.set(type, listener),
+};
+globalThis.localStorage = {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, String(value)),
+};
+globalThis.document = {
+ documentElement: {
+ setAttribute: (name, value) => attributes.set(name, value),
+ style,
+ },
+};
+
+const preference = await import("./fontSizePreference.ts");
+
+test("scales fixed line-height utilities with the typography rem", () => {
+ assert.deepEqual(config.theme.extend.lineHeight, {
+ 3: "calc(var(--buzz-type-rem) * 0.75)",
+ 4: "var(--buzz-type-rem)",
+ 5: "calc(var(--buzz-type-rem) * 1.25)",
+ 6: "calc(var(--buzz-type-rem) * 1.5)",
+ 7: "calc(var(--buzz-type-rem) * 1.75)",
+ 8: "calc(var(--buzz-type-rem) * 2)",
+ "message-author": "var(--conversation-author-line-height)",
+ });
+});
+
+test("defaults invalid and missing font sizes to default", () => {
+ assert.equal(preference.parseFontSize(null), "default");
+ assert.equal(preference.parseFontSize("medium"), "default");
+ assert.equal(preference.parseFontSize("smaller"), "smaller");
+ assert.equal(preference.parseFontSize("default"), "default");
+ assert.equal(preference.parseFontSize("larger"), "larger");
+});
+
+test("persists and applies the selected font size across the app", () => {
+ preference.applyTextZoomFactor(1);
+ preference.setFontSize("smaller");
+ assert.equal(preference.getFontSize(), "smaller");
+ assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
+ assert.equal(attributes.get("data-font-size"), "smaller");
+ assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
+});
+
+test("previews a font size without changing the saved preference", () => {
+ preference.applyTextZoomFactor(1.1);
+ preference.setFontSize("smaller");
+ preference.previewFontSize("larger");
+ assert.equal(preference.getFontSize(), "smaller");
+ assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
+ assert.equal(attributes.get("data-font-size"), "larger");
+ assert.equal(styleValues.get("--buzz-type-rem"), "18.857143px");
+
+ preference.previewFontSize(null);
+ assert.equal(attributes.get("data-font-size"), "smaller");
+ assert.equal(styleValues.get("--buzz-type-rem"), "16.342857px");
+});
+
+test("initializes from the stored font size", () => {
+ preference.applyTextZoomFactor(1);
+ values.set(preference.FONT_SIZE_STORAGE_KEY, "larger");
+ preference.initializeFontSizePreference();
+ assert.equal(preference.getFontSize(), "larger");
+ assert.equal(attributes.get("data-font-size"), "larger");
+ assert.equal(styleValues.get("--buzz-type-rem"), "17.142857px");
+});
+
+test("applies font size changes from another window", () => {
+ values.set(preference.FONT_SIZE_STORAGE_KEY, "smaller");
+ windowListeners.get("storage")({ key: preference.FONT_SIZE_STORAGE_KEY });
+ assert.equal(preference.getFontSize(), "smaller");
+ assert.equal(attributes.get("data-font-size"), "smaller");
+ assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
+});
+
+test("returns to the default when another window clears storage", () => {
+ preference.setFontSize("larger");
+ values.clear();
+ windowListeners.get("storage")({ key: null });
+ assert.equal(preference.getFontSize(), "default");
+ assert.equal(attributes.get("data-font-size"), "default");
+ assert.equal(styleValues.get("--buzz-type-rem"), "16px");
+});
diff --git a/desktop/src/shared/lib/fontSizePreference.ts b/desktop/src/shared/lib/fontSizePreference.ts
new file mode 100644
index 0000000000..9604a2c2cb
--- /dev/null
+++ b/desktop/src/shared/lib/fontSizePreference.ts
@@ -0,0 +1,121 @@
+import * as React from "react";
+
+/** Device-level type scale applied throughout the desktop interface. */
+export type FontSize = "smaller" | "default" | "larger";
+
+export const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
+export const DEFAULT_FONT_SIZE: FontSize = "default";
+
+/**
+ * Virtual rem sizes used by typography tokens. Keeping the real root at 16px
+ * prevents a text preference from also resizing rem-based layout geometry.
+ */
+const TYPE_REM_SIZE_PX: Record = {
+ smaller: 13 / 0.875,
+ default: 14 / 0.875,
+ larger: 15 / 0.875,
+};
+
+const TYPE_REM_PROPERTY = "--buzz-type-rem";
+
+const listeners = new Set<() => void>();
+let fontSize: FontSize = DEFAULT_FONT_SIZE;
+let textZoomFactor = 1;
+let listeningForStorageChanges = false;
+
+export function parseFontSize(value: string | null | undefined): FontSize {
+ return value === "smaller" || value === "default" || value === "larger"
+ ? value
+ : DEFAULT_FONT_SIZE;
+}
+
+function readStoredFontSize(): FontSize {
+ try {
+ return parseFontSize(
+ globalThis.localStorage?.getItem(FONT_SIZE_STORAGE_KEY),
+ );
+ } catch {
+ return DEFAULT_FONT_SIZE;
+ }
+}
+
+function typeRemSizePx(size: FontSize): number {
+ return (
+ Math.round(TYPE_REM_SIZE_PX[size] * textZoomFactor * 1_000_000) / 1_000_000
+ );
+}
+
+function applyFontSize(size: FontSize): void {
+ const root = globalThis.document?.documentElement;
+ root?.setAttribute("data-font-size", size);
+ root?.style.setProperty(TYPE_REM_PROPERTY, `${typeRemSizePx(size)}px`);
+}
+
+function notifyListeners(): void {
+ for (const listener of listeners) listener();
+}
+
+function applyStoredFontSize(): void {
+ const nextSize = readStoredFontSize();
+ const changed = nextSize !== fontSize;
+ fontSize = nextSize;
+ applyFontSize(nextSize);
+ if (changed) notifyListeners();
+}
+
+function listenForStorageChanges(): void {
+ if (listeningForStorageChanges || !globalThis.window?.addEventListener)
+ return;
+ globalThis.window.addEventListener("storage", (event) => {
+ if (event.key === FONT_SIZE_STORAGE_KEY || event.key === null) {
+ applyStoredFontSize();
+ }
+ });
+ listeningForStorageChanges = true;
+}
+
+/** Apply the persisted preference before React renders to avoid a layout jump. */
+export function initializeFontSizePreference(): void {
+ applyStoredFontSize();
+ listenForStorageChanges();
+}
+
+/** Combine Cmd +/- zoom with the selected app-wide type scale. */
+export function applyTextZoomFactor(zoomFactor: number): void {
+ if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
+ textZoomFactor = zoomFactor;
+ applyFontSize(fontSize);
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function getFontSize(): FontSize {
+ return fontSize;
+}
+
+export function setFontSize(size: FontSize): void {
+ fontSize = size;
+ applyFontSize(size);
+ try {
+ globalThis.localStorage?.setItem(FONT_SIZE_STORAGE_KEY, size);
+ } catch {
+ // Persistence is best-effort; the live preference still applies.
+ }
+ notifyListeners();
+}
+
+/** Temporarily apply a size without changing the saved preference. */
+export function previewFontSize(size: FontSize | null): void {
+ applyFontSize(size ?? fontSize);
+}
+
+export function useFontSize(): FontSize {
+ return React.useSyncExternalStore(
+ subscribe,
+ getFontSize,
+ () => DEFAULT_FONT_SIZE,
+ );
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 0d5a103219..53a753fd9e 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -7,6 +7,7 @@
@import "./globals/composer.css";
@import "./globals/markdown.css";
@import "./globals/theme.css";
+@import "./globals/typography.css";
@import "./globals/skeleton.css";
@import "./globals/spoilers.css";
@import "./globals/components.css";
diff --git a/desktop/src/shared/styles/globals/avatar-framing.css b/desktop/src/shared/styles/globals/avatar-framing.css
index 44c11163fd..79a2e2ad07 100644
--- a/desktop/src/shared/styles/globals/avatar-framing.css
+++ b/desktop/src/shared/styles/globals/avatar-framing.css
@@ -132,8 +132,8 @@
width: 100%;
max-width: none;
color: hsl(var(--muted-foreground) / 0.72);
- font-size: 0.875rem;
- line-height: 1.25rem;
+ font-size: calc(var(--buzz-type-rem) * 0.875);
+ line-height: calc(var(--buzz-type-rem) * 1.25);
text-align: center;
opacity: 0;
pointer-events: none;
diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css
index f206539a93..7e0e2fe116 100644
--- a/desktop/src/shared/styles/globals/components.css
+++ b/desktop/src/shared/styles/globals/components.css
@@ -558,7 +558,7 @@
.buzz-onboarding-runtime-pill {
animation: buzz-onboarding-runtime-pill-in 180ms
cubic-bezier(0.22, 1, 0.36, 1) both;
- font-size: 0.625rem;
+ font-size: calc(var(--buzz-type-rem) * 0.625);
letter-spacing: 0;
line-height: 1;
text-transform: uppercase;
diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css
index 0af4a59b4f..2ee690e41d 100644
--- a/desktop/src/shared/styles/globals/composer.css
+++ b/desktop/src/shared/styles/globals/composer.css
@@ -93,10 +93,9 @@
}
.rich-text-composer .tiptap {
+ @apply text-message font-normal tracking-normal;
outline: none;
min-height: 1lh;
- font-size: var(--text-sm);
- line-height: var(--text-sm--line-height);
}
.rich-text-composer .tiptap p {
diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css
index 544f0ae2ef..c5aa001afb 100644
--- a/desktop/src/shared/styles/globals/terminal.css
+++ b/desktop/src/shared/styles/globals/terminal.css
@@ -45,7 +45,7 @@
color: hsl(var(--muted-foreground));
display: flex;
font: inherit;
- font-size: 0.75rem;
+ font-size: calc(var(--buzz-type-rem) * 0.75);
font-weight: 500;
gap: 6px;
height: 30px;
@@ -130,7 +130,7 @@
.buzz-terminal-designator {
align-items: center;
display: inline-flex;
- font-size: 0.75rem;
+ font-size: calc(var(--buzz-type-rem) * 0.75);
gap: 2px;
letter-spacing: 0;
}
diff --git a/desktop/src/shared/styles/globals/typography.css b/desktop/src/shared/styles/globals/typography.css
new file mode 100644
index 0000000000..fa59d37241
--- /dev/null
+++ b/desktop/src/shared/styles/globals/typography.css
@@ -0,0 +1,56 @@
+@layer base {
+ :root {
+ /*
+ * A virtual typography rem. Font preferences and Cmd +/- change this
+ * token while the browser root remains 16px, so text scales without also
+ * resizing rem-based widths, gaps, radii, and controls.
+ */
+ --buzz-type-rem: 1rem;
+ --text-xs: calc(var(--buzz-type-rem) * 0.75);
+ --text-sm: calc(var(--buzz-type-rem) * 0.875);
+ --text-base: var(--buzz-type-rem);
+ --text-lg: calc(var(--buzz-type-rem) * 1.125);
+ --text-xl: calc(var(--buzz-type-rem) * 1.25);
+ --text-2xl: calc(var(--buzz-type-rem) * 1.5);
+ --text-3xl: calc(var(--buzz-type-rem) * 1.875);
+ --text-4xl: calc(var(--buzz-type-rem) * 2.25);
+ --text-5xl: calc(var(--buzz-type-rem) * 3);
+ --text-6xl: calc(var(--buzz-type-rem) * 3.75);
+
+ /*
+ * Default conversation type and comfy spacing for channels, DMs, threads,
+ * Inbox, and the composer. Font size changes the type tokens only;
+ * Conversation density overrides only spacing.
+ */
+ --conversation-message-font-size: calc(var(--buzz-type-rem) * 0.875);
+ --conversation-message-line-height: calc(var(--buzz-type-rem) * 1.25);
+ --conversation-author-line-height: var(--buzz-type-rem);
+ --conversation-body-gap: 0.125rem;
+ --conversation-row-padding-block: 0.25rem;
+ --conversation-paragraph-gap: 0.5rem;
+ --conversation-list-item-gap: 0.375rem;
+ --conversation-timestamp-font-size: calc(var(--buzz-type-rem) * 0.75);
+ --conversation-timestamp-line-height: var(--buzz-type-rem);
+ }
+
+ :root[data-conversation-density="compact"] {
+ --conversation-body-gap: 0rem;
+ --conversation-row-padding-block: 0.25rem;
+ --conversation-paragraph-gap: 0.375rem;
+ --conversation-list-item-gap: 0.25rem;
+ }
+
+ :root[data-conversation-density="spacious"] {
+ --conversation-body-gap: 0.25rem;
+ --conversation-row-padding-block: 0.5rem;
+ --conversation-paragraph-gap: 0.625rem;
+ --conversation-list-item-gap: 0.5rem;
+ }
+
+ body {
+ font-synthesis-weight: none;
+ font-variant-emoji: unicode;
+ font-variant-ligatures: no-contextual;
+ text-rendering: optimizeLegibility;
+ }
+}
diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx
index 153801787b..fb5ac15a44 100644
--- a/desktop/src/shared/ui/PubKey.tsx
+++ b/desktop/src/shared/ui/PubKey.tsx
@@ -6,9 +6,13 @@ import { cn } from "@/shared/lib/cn";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
-import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/shared/ui/popover";
-const HOVER_OPEN_DELAY_MS = 500;
const HOVER_CLOSE_DELAY_MS = 200;
type PubKeyProps = {
@@ -99,7 +103,7 @@ export function PubKey({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
- }, HOVER_OPEN_DELAY_MS);
+ }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer]);
const handleMouseLeave = React.useCallback(() => {
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index a556278ead..6326709d49 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -1877,10 +1877,10 @@ function MarkdownInner({
className={cn(
MESSAGE_MARKDOWN_CLASS,
[
- "max-w-none wrap-anywhere text-sm leading-5 text-foreground",
+ "max-w-none wrap-anywhere text-message font-normal tracking-normal text-foreground",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&>*+*]:mt-3",
- "[&>p+p]:mt-1.5",
+ "[&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list",
"[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5",
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5",
"[&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5!",
diff --git a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx
index 89b482ae0c..1082ff2032 100644
--- a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx
+++ b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx
@@ -1,6 +1,11 @@
import * as React from "react";
-import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/shared/ui/popover";
export function InlineEmojiPopover({
alt,
@@ -27,7 +32,10 @@ export function InlineEmojiPopover({
const handleMouseEnter = React.useCallback(() => {
clearTimers();
- openTimeout.current = setTimeout(() => setOpen(true), 200);
+ openTimeout.current = setTimeout(
+ () => setOpen(true),
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ );
}, [clearTimers]);
const scheduleClose = React.useCallback(() => {
diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx
index 4c161efe5d..ede0e15627 100644
--- a/desktop/src/shared/ui/popover.tsx
+++ b/desktop/src/shared/ui/popover.tsx
@@ -14,6 +14,10 @@ import {
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
+// Radix Popover has no hover timing API: controlled hover popovers must use this
+// shared dwell default themselves. Keep click and keyboard opens immediate.
+export const DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS = 500;
+
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
diff --git a/desktop/src/shared/ui/segmented-control.tsx b/desktop/src/shared/ui/segmented-control.tsx
new file mode 100644
index 0000000000..78cd280bae
--- /dev/null
+++ b/desktop/src/shared/ui/segmented-control.tsx
@@ -0,0 +1,218 @@
+import * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+
+type SegmentOption = {
+ value: Value;
+ label: string;
+ Icon?: React.ComponentType<{ className?: string }>;
+};
+
+type SegmentedControlSize = "compact" | "default" | "wide";
+
+const SIZE_CLASSES: Record = {
+ compact: "w-48",
+ default: "w-60",
+ wide: "w-72",
+};
+
+/** A mutually exclusive control with equal-width, optionally scrubbable options. */
+export function SegmentedControl({
+ className,
+ indicatorTestId,
+ legend,
+ onPreviewChange,
+ onValueChange,
+ optionTestIdPrefix,
+ options,
+ size = "default",
+ testId,
+ value,
+}: {
+ className?: string;
+ indicatorTestId?: string;
+ legend: string;
+ onPreviewChange?: (value: Value | null) => void;
+ onValueChange: (value: Value) => void;
+ optionTestIdPrefix: string;
+ options: readonly SegmentOption[];
+ size?: SegmentedControlSize;
+ testId: string;
+ value: Value;
+}) {
+ const [previewValue, setPreviewValue] = React.useState(null);
+ const controlRef = React.useRef(null);
+ const activePointerIdRef = React.useRef(null);
+ const pointerStartXRef = React.useRef(null);
+ const pointerStartValueRef = React.useRef(null);
+ const scrubValueRef = React.useRef(null);
+ const skipPointerClickRef = React.useRef(false);
+ const displayedValue = previewValue ?? value;
+ const selectedIndex = Math.max(
+ 0,
+ options.findIndex((option) => option.value === displayedValue),
+ );
+
+ const getValueAtPointer = React.useCallback(
+ (element: HTMLFieldSetElement, clientX: number): Value => {
+ const bounds = element.getBoundingClientRect();
+ const position = Math.max(
+ 0,
+ Math.min(bounds.width - 1, clientX - bounds.left),
+ );
+ const index = Math.min(
+ options.length - 1,
+ Math.floor((position / bounds.width) * options.length),
+ );
+ return options[index]?.value ?? value;
+ },
+ [options, value],
+ );
+
+ const preview = React.useCallback(
+ (nextValue: Value | null) => {
+ scrubValueRef.current = nextValue;
+ setPreviewValue(nextValue);
+ onPreviewChange?.(nextValue);
+ },
+ [onPreviewChange],
+ );
+
+ const cancelScrub = React.useCallback(() => {
+ const control = controlRef.current;
+ const pointerId = activePointerIdRef.current;
+ activePointerIdRef.current = null;
+ if (control && pointerId != null && control.hasPointerCapture(pointerId)) {
+ control.releasePointerCapture(pointerId);
+ }
+ pointerStartXRef.current = null;
+ pointerStartValueRef.current = null;
+ skipPointerClickRef.current = false;
+ preview(null);
+ }, [preview]);
+
+ React.useEffect(() => {
+ const handleWindowBlur = () => cancelScrub();
+ globalThis.addEventListener?.("blur", handleWindowBlur);
+ return () => {
+ globalThis.removeEventListener?.("blur", handleWindowBlur);
+ cancelScrub();
+ };
+ }, [cancelScrub]);
+
+ const handlePointerDown = (
+ event: React.PointerEvent,
+ ) => {
+ if (!onPreviewChange || event.button !== 0) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ activePointerIdRef.current = event.pointerId;
+ pointerStartXRef.current = event.clientX;
+ pointerStartValueRef.current = getValueAtPointer(
+ event.currentTarget,
+ event.clientX,
+ );
+ scrubValueRef.current = null;
+ skipPointerClickRef.current = true;
+ event.preventDefault();
+ };
+
+ const handlePointerMove = (
+ event: React.PointerEvent,
+ ) => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
+ const pointerStartX = pointerStartXRef.current;
+ const pointerStartValue = pointerStartValueRef.current;
+ const crossedDragThreshold =
+ pointerStartX != null && Math.abs(event.clientX - pointerStartX) >= 4;
+ if (
+ scrubValueRef.current == null &&
+ !crossedDragThreshold &&
+ nextValue === pointerStartValue
+ ) {
+ return;
+ }
+ if (nextValue !== scrubValueRef.current) preview(nextValue);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
+ activePointerIdRef.current = null;
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ pointerStartXRef.current = null;
+ pointerStartValueRef.current = null;
+ onValueChange(nextValue);
+ preview(null);
+ globalThis.setTimeout(() => {
+ skipPointerClickRef.current = false;
+ }, 0);
+ };
+
+ const handlePointerCancel = () => cancelScrub();
+
+ const handleLostPointerCapture = () => {
+ if (activePointerIdRef.current != null) cancelScrub();
+ };
+
+ return (
+
+ {legend}
+
+ {/* Legends escape grid/flex layout on a fieldset, so the columns live
+ on an inner wrapper the legend is not part of. */}
+
+ {options.map(({ value: optionValue, label, Icon }) => (
+ {
+ if (event.detail > 0 && skipPointerClickRef.current) {
+ skipPointerClickRef.current = false;
+ return;
+ }
+ onValueChange(optionValue);
+ }}
+ type="button"
+ >
+ {Icon ? : null}
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx
index 41db47b15c..5dbda32987 100644
--- a/desktop/src/shared/ui/sidebar.tsx
+++ b/desktop/src/shared/ui/sidebar.tsx
@@ -24,7 +24,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
-
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width";
@@ -37,7 +36,8 @@ const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
-
+// Increases button hit areas on mobile without changing their layout.
+const MOBILE_ACTION_HIT_AREA = "after:absolute after:-inset-2 after:md:hidden";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
@@ -52,7 +52,6 @@ type SidebarContextProps = {
setSidebarWidth: (width: number | ((width: number) => number)) => void;
toggleSidebar: () => void;
};
-
const SidebarContext = React.createContext(null);
function useSidebar() {
@@ -133,7 +132,6 @@ function readSidebarWidth() {
? clampSidebarWidth(storedWidth)
: SIDEBAR_WIDTH_DEFAULT;
}
-
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
@@ -220,10 +218,8 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
+ // Expose semantic state so Tailwind descendants can style both modes.
const state = open ? "expanded" : "collapsed";
-
const contextValue = React.useMemo(
() => ({
state,
@@ -255,7 +251,7 @@ const SidebarProvider = React.forwardRef<
return (
-
+
@@ -381,7 +378,12 @@ const Sidebar = React.forwardRef<
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
- {children}
+
+ {children}
+
@@ -707,8 +709,7 @@ const SidebarGroupAction = React.forwardRef<
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 after:md:hidden",
+ MOBILE_ACTION_HIT_AREA,
"group-data-[collapsible=icon]:hidden",
className,
)}
@@ -853,8 +854,7 @@ const SidebarMenuAction = React.forwardRef<
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 after:md:hidden",
+ MOBILE_ACTION_HIT_AREA,
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx
index afa58c753e..6af9e48188 100644
--- a/desktop/src/shared/ui/tooltip.tsx
+++ b/desktop/src/shared/ui/tooltip.tsx
@@ -3,7 +3,23 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/cn";
-const TooltipProvider = TooltipPrimitive.Provider;
+// Hover-only disclosure should require deliberate pointer dwell. Disabling Radix's
+// skip-delay grace prevents tooltips from cascading open while the pointer moves
+// across adjacent controls. Callers may override both values for a proven case.
+const DEFAULT_TOOLTIP_DELAY_MS = 500;
+const DEFAULT_TOOLTIP_SKIP_DELAY_MS = 0;
+
+const TooltipProvider = ({
+ delayDuration = DEFAULT_TOOLTIP_DELAY_MS,
+ skipDelayDuration = DEFAULT_TOOLTIP_SKIP_DELAY_MS,
+ ...props
+}: React.ComponentProps
) => (
+
+);
const Tooltip = TooltipPrimitive.Root;
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1aa98ca4a7..1f7b147749 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -411,6 +411,10 @@ type E2eConfig = {
nostrBindSignDelayMs?: number;
/** Reject successive mock WebSocket connect attempts, then resume. */
websocketConnectErrors?: string[];
+ /** Deliver AUTH synchronously, before the mock connect command resolves. */
+ websocketAuthBeforeConnectResolves?: boolean;
+ /** Stall the first AUTH signing command forever; later attempts complete. */
+ stallFirstAuthSigning?: boolean;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -1813,6 +1817,12 @@ function buildMockConfigSurface(pubkey: string): {
sources: Record;
} {
// Goose running — mixed origins, override on model
+ // The `writeVia` payloads below are camelCase because that is what the
+ // backend emits — pinned by `wire_format_matches_typescript_contract` in
+ // `desktop/src-tauri/src/managed_agents/config_bridge/types.rs`. This mock
+ // agreed with `api/types.ts` while the real serializer emitted `env_key` /
+ // `config_id` / `config_key`, so a test against it certified a contract
+ // nothing produced. Change these only alongside that Rust test.
const gooseSurface = {
runtimeId: "goose",
runtimeLabel: "Goose",
@@ -3062,6 +3072,7 @@ const mockAuthResponses: Array<{ success: boolean; message: string }> = [];
const mockChannelHistoryCloses: string[] = [];
let mockWebsocketUnavailable = false;
const relayWebsocketConnectAttemptStarts: number[] = [];
+let mockAuthSigningAttempts = 0;
let mockWebsocketSendMutexWedged = false;
let mockClosedChannelLiveSubscription = false;
const realSockets = new Map();
@@ -9757,9 +9768,14 @@ async function connectMockSocket(args: { onMessage: unknown }) {
subscriptions: new Map(),
});
- window.setTimeout(() => {
+ if (getConfig()?.mock?.websocketAuthBeforeConnectResolves) {
sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
- }, 0);
+ await new Promise((resolve) => window.setTimeout(resolve, 50));
+ } else {
+ window.setTimeout(() => {
+ sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
+ }, 0);
+ }
return wsId;
}
@@ -10248,6 +10264,7 @@ export function maybeInstallE2eTauriMocks() {
mockAuthResponses.length = 0;
mockChannelHistoryCloses.length = 0;
relayWebsocketConnectAttemptStarts.length = 0;
+ mockAuthSigningAttempts = 0;
deferredSendMessageLiveEchoes.length = 0;
deferredLinkPreviewMetadataQueue = [];
deferredLinkPreviewUploadQueue = [];
@@ -13131,6 +13148,13 @@ export function maybeInstallE2eTauriMocks() {
case "nip44_decrypt_from_self":
return (payload as { ciphertext: string }).ciphertext;
case "create_auth_event":
+ mockAuthSigningAttempts++;
+ if (
+ getConfig()?.mock?.stallFirstAuthSigning &&
+ mockAuthSigningAttempts === 1
+ ) {
+ return new Promise(() => {});
+ }
if (identity) {
return JSON.stringify(
await signWithIdentity(identity, {
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 8905fc16ce..07d00b0db9 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -3,18 +3,46 @@ export default {
theme: {
extend: {
// Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking
- // labels) and tiny glyphs. Defined in rem so Cmd +/- zoom — which scales
- // the root font-size — keeps scaling them. Do NOT reintroduce
- // arbitrary `text-[…rem]` / `text-[…px]` literals; the px-text guard
- // rejects them. Stock scale picks up from here: xs (12px), sm (14px)…
+ // labels) and tiny glyphs. These follow the virtual typography rem so
+ // preferences and Cmd +/- scale text without changing layout geometry.
+ // Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals;
+ // the px-text guard rejects them. Stock scale picks up from xs.
fontSize: {
- "2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
- "3xs": "0.5rem", // 8px — tiny glyphs / micro labels
- badge: "0.625rem", // 10px — compact status badges
- // 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" }],
+ "2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem
+ "3xs": "calc(var(--buzz-type-rem) * 0.5)", // 8px at 16px type rem
+ badge: "calc(var(--buzz-type-rem) * 0.625)", // 10px at 16px type rem
+ // Shared channel, DM, thread, and composer type. Variables keep app-wide
+ // font size and keyboard zoom consistent without branching components.
+ message: [
+ "var(--conversation-message-font-size)",
+ { lineHeight: "var(--conversation-message-line-height)" },
+ ],
+ "message-timestamp": [
+ "var(--conversation-timestamp-font-size)",
+ { lineHeight: "var(--conversation-timestamp-line-height)" },
+ ],
+ // 40px at the 16px type rem — onboarding page titles.
+ title: [
+ "calc(var(--buzz-type-rem) * 2.5)",
+ { lineHeight: "1.15", letterSpacing: "-0.02em" },
+ ],
+ // 36px at the 16px type rem — backup-step private key.
+ "nsec-key": [
+ "calc(var(--buzz-type-rem) * 2.25)",
+ { lineHeight: "1.3" },
+ ],
+ },
+ lineHeight: {
+ // Keep fixed Tailwind line-height utilities in the typography scale so
+ // Cmd +/- cannot enlarge glyphs inside an unchanged line box. Single-
+ // line surfaces keep their existing truncate/overflow behavior.
+ 3: "calc(var(--buzz-type-rem) * 0.75)",
+ 4: "var(--buzz-type-rem)",
+ 5: "calc(var(--buzz-type-rem) * 1.25)",
+ 6: "calc(var(--buzz-type-rem) * 1.5)",
+ 7: "calc(var(--buzz-type-rem) * 1.75)",
+ 8: "calc(var(--buzz-type-rem) * 2)",
+ "message-author": "var(--conversation-author-line-height)",
},
boxShadow: {
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
@@ -36,6 +64,10 @@ export default {
},
spacing: {
4.5: "1.125rem",
+ "conversation-body": "var(--conversation-body-gap)",
+ "conversation-list": "var(--conversation-list-item-gap)",
+ "conversation-paragraph": "var(--conversation-paragraph-gap)",
+ "conversation-row": "var(--conversation-row-padding-block)",
},
fontFamily: {
sans: [
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index e191d29304..b81c5889f3 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -747,6 +747,8 @@ test("moves agent actions into an overflow menu in a narrow view", async ({
});
await expect(page.getByTestId("agent-defaults-button")).toBeVisible();
+ // The app-wide default renders text-base at 16px with Tailwind's 1.5
+ // line-height ratio, producing a 24px one-line scroll height.
await expect(
page.getByText("Set up and manage your agents.", { exact: true }),
).toHaveJSProperty("scrollHeight", 24);
@@ -2287,7 +2289,9 @@ test("people sharing blocks a timeout before encoding or upload", async ({
});
await page.getByTestId("persona-share-send").click();
- await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible();
+ await expect(
+ page.getByText("You are currently timed out and cannot send messages."),
+ ).toBeVisible();
const commands = await readAgentShareCommands(page);
expect(
@@ -2332,7 +2336,11 @@ test("people sharing rechecks destination eligibility after encoding", async ({
return testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__?.();
});
- await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible({
+ await expect(
+ page.getByText(
+ "The selected destination is no longer available. Please pick another.",
+ ),
+ ).toBeVisible({
timeout: 5_000,
});
const commands = await readAgentShareCommands(page);
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index 2bcd36908e..0c01bfd660 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -8,6 +8,8 @@ const THEME_STORAGE_KEY = "buzz-theme";
const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background";
const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity";
const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab";
+const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
+const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
@@ -617,7 +619,7 @@ test("appearance groups theme and preferences into labeled rows", async ({
(lightModeButtonBox.x + lightModeButtonBox.width / 2),
),
).toBeLessThanOrEqual(0.5);
- await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.25s");
+ await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s");
await themeCard.getByTestId("appearance-mode-dark").click();
await waitForAnimations(page);
@@ -639,6 +641,512 @@ test("appearance groups theme and preferences into labeled rows", async ({
).toBeLessThanOrEqual(0.5);
});
+test("app font size and conversation density apply independently", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await openAppearance(page, "light");
+
+ const root = page.locator("html");
+ const densityControl = page.getByTestId("conversation-density-control");
+ const compact = page.getByTestId("conversation-density-compact");
+ const comfortable = page.getByTestId("conversation-density-comfortable");
+ const spacious = page.getByTestId("conversation-density-spacious");
+ const densityIndicator = page.getByTestId(
+ "conversation-density-control-indicator",
+ );
+ const fontSizeControl = page.getByTestId("font-size-control");
+ const smaller = page.getByTestId("font-size-smaller");
+ const defaultSize = page.getByTestId("font-size-default");
+ const larger = page.getByTestId("font-size-larger");
+ const fontSizeIndicator = page.getByTestId("font-size-control-indicator");
+ const preview = page.getByTestId("conversation-preview");
+ const previewSurface = page.getByTestId("conversation-preview-surface");
+ const previewContent = page.getByTestId("conversation-preview-content");
+ const previewChip = preview.getByText("Preview");
+ const firstPreviewMessage = previewSurface.locator("article").first();
+ const previewMessage = preview.getByText(
+ "The revised conversation layout is ready to review.",
+ );
+ const previewTimestamp = preview.getByText("9:41");
+ const densityDescription = page
+ .getByTestId("conversation-density-row")
+ .locator("[data-settings-subcopy]");
+ const fontSizeDescription = page
+ .getByTestId("font-size-row")
+ .locator("[data-settings-subcopy]");
+ const readScale = () =>
+ root.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ authorLineHeight: Number.parseFloat(
+ style.getPropertyValue("--conversation-author-line-height"),
+ ),
+ bodyGap: Number.parseFloat(
+ style.getPropertyValue("--conversation-body-gap"),
+ ),
+ fontSize: style.getPropertyValue("--conversation-message-font-size"),
+ lineHeight: style.getPropertyValue(
+ "--conversation-message-line-height",
+ ),
+ paragraphGap: Number.parseFloat(
+ style.getPropertyValue("--conversation-paragraph-gap"),
+ ),
+ rowPadding: Number.parseFloat(
+ style.getPropertyValue("--conversation-row-padding-block"),
+ ),
+ timestampFontSize: style.getPropertyValue(
+ "--conversation-timestamp-font-size",
+ ),
+ timestampLineHeight: Number.parseFloat(
+ style.getPropertyValue("--conversation-timestamp-line-height"),
+ ),
+ };
+ });
+ const readSettingsScale = () =>
+ page.getByTestId("conversation-density-row").evaluate((element) => {
+ const rowStyle = window.getComputedStyle(element);
+ const label = element.querySelector("p");
+ if (!label) throw new Error("Conversation density label is missing");
+ const labelStyle = window.getComputedStyle(label);
+ return {
+ fontSize: labelStyle.fontSize,
+ lineHeight: labelStyle.lineHeight,
+ minHeight: rowStyle.minHeight,
+ paddingBlock: rowStyle.paddingTop,
+ };
+ });
+ const readPreviewTimestampScale = () =>
+ previewTimestamp.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return [style.fontSize, style.lineHeight];
+ });
+ const readSettingsChromeScale = () =>
+ Promise.all([
+ page
+ .getByRole("heading", { name: "Appearance" })
+ .evaluate((element) => window.getComputedStyle(element).fontSize),
+ page
+ .getByTestId("settings-nav-appearance")
+ .evaluate((element) => window.getComputedStyle(element).fontSize),
+ page
+ .getByRole("heading", { name: "Preferences" })
+ .evaluate((element) => window.getComputedStyle(element).fontSize),
+ ]);
+
+ await expect(
+ page.getByRole("group", { name: "Conversation density" }),
+ ).toBeVisible();
+ await expect(page.getByRole("group", { name: "Font size" })).toBeVisible();
+ await expect(densityControl).toHaveAccessibleName("Conversation density");
+ await expect(fontSizeControl).toHaveAccessibleName("Font size");
+ await expect(comfortable).toHaveText("Comfy");
+ await expect(defaultSize).toHaveText("Default");
+ await expect(preview).toContainText("Preview");
+ await expect(preview).not.toContainText("Message #design");
+ await expect(comfortable).toHaveAttribute("aria-pressed", "true");
+ await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
+ await expect(densityDescription).toHaveText(
+ "Spacing in conversations and Markdown content across Buzz",
+ );
+ await expect(fontSizeDescription).toHaveText(
+ "Applies across conversations and interface text",
+ );
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 16,
+ bodyGap: 0.125,
+ fontSize: "calc(16px * .875)",
+ lineHeight: "calc(16px * 1.25)",
+ paragraphGap: 0.5,
+ rowPadding: 0.25,
+ timestampFontSize: "calc(16px * .75)",
+ timestampLineHeight: 16,
+ });
+ await expect
+ .poll(() =>
+ Promise.all([
+ densityControl.evaluate(
+ (element) => element.getBoundingClientRect().width,
+ ),
+ fontSizeControl.evaluate(
+ (element) => element.getBoundingClientRect().width,
+ ),
+ page
+ .getByTestId("appearance-color-mode-control")
+ .evaluate((element) => element.getBoundingClientRect().width),
+ ]),
+ )
+ .toEqual([288, 288, 240]);
+ await expect
+ .poll(() =>
+ previewMessage.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return [style.fontSize, style.lineHeight];
+ }),
+ )
+ .toEqual(["14px", "20px"]);
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "14px",
+ lineHeight: "20px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
+ await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
+ await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s");
+ await expect(densityIndicator).toHaveCSS("transition-property", /transform/);
+ await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s");
+ await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/);
+ await expect
+ .poll(async () => {
+ const [previewBackground, labelBackground, controlBackground] =
+ await Promise.all([
+ previewSurface.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ previewChip.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ densityControl.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ ]);
+ return {
+ labelIsAnnotation: labelBackground !== controlBackground,
+ previewBackground,
+ };
+ })
+ .toEqual({
+ labelIsAnnotation: true,
+ previewBackground: "rgba(0, 0, 0, 0)",
+ });
+ const previewSurfaceBox = await previewSurface.boundingBox();
+ const previewChipBox = await previewChip.boundingBox();
+ const firstPreviewMessageBox = await firstPreviewMessage.boundingBox();
+ expect(previewSurfaceBox).not.toBeNull();
+ expect(previewChipBox).not.toBeNull();
+ expect(firstPreviewMessageBox).not.toBeNull();
+ if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) {
+ throw new Error("Conversation preview geometry is missing");
+ }
+ const previewChipRightInset =
+ previewSurfaceBox.x +
+ previewSurfaceBox.width -
+ (previewChipBox.x + previewChipBox.width);
+ expect(previewChipRightInset).toBeGreaterThanOrEqual(13);
+ expect(previewChipRightInset).toBeLessThanOrEqual(15);
+ const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
+ expect(previewChipTopInset).toBeGreaterThanOrEqual(13);
+ expect(previewChipTopInset).toBeLessThanOrEqual(15);
+ await expect(previewContent).toHaveCSS("padding-top", "16px");
+ await expect(previewContent).toHaveCSS("padding-right", "16px");
+ await expect(previewContent).toHaveCSS("padding-bottom", "16px");
+ await expect(previewContent).toHaveCSS("padding-left", "16px");
+ expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual(
+ 15,
+ );
+ expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
+ 17,
+ );
+ expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
+ 15,
+ );
+ expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
+ 17,
+ );
+ await densityIndicator.evaluate((element) => {
+ element.addEventListener(
+ "transitionrun",
+ () => element.setAttribute("data-transition-ran", "true"),
+ { once: true },
+ );
+ });
+
+ await compact.click();
+ await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true");
+ await expect(root).toHaveAttribute("data-conversation-density", "compact");
+ await expect(root).toHaveAttribute("data-font-size", "default");
+ await expect(compact).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ CONVERSATION_DENSITY_STORAGE_KEY,
+ ),
+ )
+ .toBe("compact");
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 16,
+ bodyGap: 0,
+ fontSize: "calc(16px * .875)",
+ lineHeight: "calc(16px * 1.25)",
+ paragraphGap: 0.375,
+ rowPadding: 0.25,
+ timestampFontSize: "calc(16px * .75)",
+ timestampLineHeight: 16,
+ });
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "14px",
+ lineHeight: "20px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
+ await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
+
+ await larger.click();
+ await expect(root).toHaveAttribute("data-conversation-density", "compact");
+ await expect(root).toHaveAttribute("data-font-size", "larger");
+ await expect(larger).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ FONT_SIZE_STORAGE_KEY,
+ ),
+ )
+ .toBe("larger");
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 17.142857,
+ bodyGap: 0,
+ fontSize: "calc(17.142857px * .875)",
+ lineHeight: "calc(17.142857px * 1.25)",
+ paragraphGap: 0.375,
+ rowPadding: 0.25,
+ timestampFontSize: "calc(17.142857px * .75)",
+ timestampLineHeight: 17.142857,
+ });
+ await expect
+ .poll(() =>
+ previewMessage.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return [style.fontSize, style.lineHeight];
+ }),
+ )
+ .toEqual(["15px", "21.4286px"]);
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "15px",
+ lineHeight: "21.4286px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await expect
+ .poll(readPreviewTimestampScale)
+ .toEqual(["12.8571px", "17.1429px"]);
+ await expect
+ .poll(readSettingsChromeScale)
+ .toEqual(["25.7143px", "15px", "15px"]);
+ await waitForAnimations(page);
+ await page.getByTestId("appearance-preferences-card").screenshot({
+ path: `${SHOTS}/15-conversation-compact-larger.png`,
+ });
+
+ await spacious.click();
+ await expect(root).toHaveAttribute("data-conversation-density", "spacious");
+ await expect(root).toHaveAttribute("data-font-size", "larger");
+ await expect(spacious).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 17.142857,
+ bodyGap: 0.25,
+ fontSize: "calc(17.142857px * .875)",
+ lineHeight: "calc(17.142857px * 1.25)",
+ paragraphGap: 0.625,
+ rowPadding: 0.5,
+ timestampFontSize: "calc(17.142857px * .75)",
+ timestampLineHeight: 17.142857,
+ });
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "15px",
+ lineHeight: "21.4286px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await expect
+ .poll(readPreviewTimestampScale)
+ .toEqual(["12.8571px", "17.1429px"]);
+ await expect
+ .poll(readSettingsChromeScale)
+ .toEqual(["25.7143px", "15px", "15px"]);
+
+ await smaller.click();
+ await expect(root).toHaveAttribute("data-conversation-density", "spacious");
+ await expect(root).toHaveAttribute("data-font-size", "smaller");
+ await expect(smaller).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 14.857143,
+ bodyGap: 0.25,
+ fontSize: "calc(14.857143px * .875)",
+ lineHeight: "calc(14.857143px * 1.25)",
+ paragraphGap: 0.625,
+ rowPadding: 0.5,
+ timestampFontSize: "calc(14.857143px * .75)",
+ timestampLineHeight: 14.857143,
+ });
+ await expect
+ .poll(() =>
+ previewMessage.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return [style.fontSize, style.lineHeight];
+ }),
+ )
+ .toEqual(["13px", "18.5714px"]);
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "13px",
+ lineHeight: "18.5714px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await expect
+ .poll(readPreviewTimestampScale)
+ .toEqual(["11.1429px", "14.8571px"]);
+ await expect
+ .poll(readSettingsChromeScale)
+ .toEqual(["22.2857px", "13px", "13px"]);
+ await waitForAnimations(page);
+ await page.getByTestId("appearance-preferences-card").screenshot({
+ path: `${SHOTS}/16-conversation-spacious-smaller.png`,
+ });
+
+ await comfortable.click();
+ await defaultSize.click();
+ await expect(root).toHaveAttribute(
+ "data-conversation-density",
+ "comfortable",
+ );
+ await expect(comfortable).toHaveAttribute("aria-pressed", "true");
+
+ const controlBox = await densityControl.boundingBox();
+ const compactBox = await compact.boundingBox();
+ const spaciousBox = await spacious.boundingBox();
+ expect(controlBox).not.toBeNull();
+ expect(compactBox).not.toBeNull();
+ expect(spaciousBox).not.toBeNull();
+ if (!controlBox || !compactBox || !spaciousBox) {
+ throw new Error("Conversation density control geometry is missing");
+ }
+ await page.mouse.move(
+ compactBox.x + compactBox.width / 2,
+ controlBox.y + controlBox.height / 2,
+ );
+ await page.mouse.down();
+ await page.mouse.move(
+ spaciousBox.x + spaciousBox.width / 2,
+ controlBox.y + controlBox.height / 2,
+ );
+ await expect(root).toHaveAttribute("data-conversation-density", "spacious");
+ await expect(root).toHaveAttribute("data-font-size", "default");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ CONVERSATION_DENSITY_STORAGE_KEY,
+ ),
+ )
+ .toBe("comfortable");
+ await expect.poll(readScale).toEqual({
+ authorLineHeight: 16,
+ bodyGap: 0.25,
+ fontSize: "calc(16px * .875)",
+ lineHeight: "calc(16px * 1.25)",
+ paragraphGap: 0.625,
+ rowPadding: 0.5,
+ timestampFontSize: "calc(16px * .75)",
+ timestampLineHeight: 16,
+ });
+ await expect.poll(readSettingsScale).toEqual({
+ fontSize: "14px",
+ lineHeight: "20px",
+ minHeight: "64px",
+ paddingBlock: "12px",
+ });
+ await page.mouse.up();
+ await expect(spacious).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ CONVERSATION_DENSITY_STORAGE_KEY,
+ ),
+ )
+ .toBe("spacious");
+ await comfortable.click();
+
+ const fontSizeControlBox = await fontSizeControl.boundingBox();
+ const smallerBox = await smaller.boundingBox();
+ const largerBox = await larger.boundingBox();
+ expect(fontSizeControlBox).not.toBeNull();
+ expect(smallerBox).not.toBeNull();
+ expect(largerBox).not.toBeNull();
+ if (!fontSizeControlBox || !smallerBox || !largerBox) {
+ throw new Error("Font size control geometry is missing");
+ }
+ await page.mouse.move(
+ smallerBox.x + smallerBox.width / 2,
+ fontSizeControlBox.y + fontSizeControlBox.height / 2,
+ );
+ await page.mouse.down();
+ await page.mouse.move(
+ largerBox.x + largerBox.width / 2,
+ fontSizeControlBox.y + fontSizeControlBox.height / 2,
+ );
+ await expect(root).toHaveAttribute("data-font-size", "larger");
+ await expect(root).toHaveAttribute(
+ "data-conversation-density",
+ "comfortable",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ FONT_SIZE_STORAGE_KEY,
+ ),
+ )
+ .toBe("default");
+ await expect
+ .poll(() =>
+ previewMessage.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return [style.fontSize, style.lineHeight];
+ }),
+ )
+ .toEqual(["15px", "21.4286px"]);
+ await expect
+ .poll(readSettingsChromeScale)
+ .toEqual(["25.7143px", "15px", "15px"]);
+
+ // Losing the window during a scrub cancels the temporary preview rather
+ // than leaving presentation and persisted selection out of sync.
+ await page.evaluate(() => window.dispatchEvent(new Event("blur")));
+ await expect(root).toHaveAttribute("data-font-size", "default");
+ await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ FONT_SIZE_STORAGE_KEY,
+ ),
+ )
+ .toBe("default");
+
+ // Cancellation resets the gesture completely; the next selection persists.
+ await larger.click();
+ await expect(larger).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.localStorage.getItem(key),
+ FONT_SIZE_STORAGE_KEY,
+ ),
+ )
+ .toBe("larger");
+ await defaultSize.click();
+ await waitForAnimations(page);
+ await page.getByTestId("appearance-preferences-card").screenshot({
+ path: `${SHOTS}/14-conversation-preferences.png`,
+ });
+});
+
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
@@ -1051,6 +1559,10 @@ test("glass background keeps the content panel solid", async ({ page }) => {
const matchingRadiusControls = [
page.getByTestId("appearance-color-mode-control"),
page.getByTestId("appearance-color-mode-indicator"),
+ page.getByTestId("font-size-control"),
+ page.getByTestId("font-size-control-indicator"),
+ page.getByTestId("conversation-density-control"),
+ page.getByTestId("conversation-density-control-indicator"),
page.getByTestId("theme-style-trigger"),
page.getByTestId("link-preview-style-trigger"),
page.getByTestId("thread-layout-trigger"),
diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index 3f6d38602a..57b35db386 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
const RELAY_URL = "ws://localhost:3000";
+const THEME_STORAGE_KEY = "buzz-theme";
const OWNER_PUBKEY = "deadbeef".repeat(8);
function snapshotKey(relayUrl: string) {
@@ -23,6 +24,25 @@ const COMMUNITY_B = {
addedAt: "2026-01-02T00:00:00.000Z",
};
+async function expectContentSurfaceHorizontalGutters(
+ page: import("@playwright/test").Page,
+ expectedLeftGutter = 1,
+) {
+ const [mainInsetBox, contentBox] = await Promise.all([
+ page.locator("[data-buzz-glass-inset]").boundingBox(),
+ page.locator("[data-buzz-content-surface]").first().boundingBox(),
+ ]);
+ expect(mainInsetBox).not.toBeNull();
+ expect(contentBox).not.toBeNull();
+ const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0);
+ const rightGutter =
+ (mainInsetBox?.x ?? 0) +
+ (mainInsetBox?.width ?? 0) -
+ ((contentBox?.x ?? 0) + (contentBox?.width ?? 0));
+ expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5);
+ expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5);
+}
+
async function seedCommunities(
page: import("@playwright/test").Page,
communities: Array>,
@@ -64,7 +84,7 @@ test.describe("community rail", () => {
"overflow",
"visible",
);
- await expect(rail).toHaveCSS("z-index", "0");
+ await expect(rail).toHaveCSS("z-index", "20");
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
@@ -137,6 +157,7 @@ test.describe("community rail", () => {
// The add-community affordance lives at the bottom of the rail.
await expect(page.getByTestId("community-rail-add")).toBeVisible();
+ await expectContentSurfaceHorizontalGutters(page);
});
test("restores pointer events after dismissing community settings", async ({
@@ -275,10 +296,87 @@ test.describe("community rail", () => {
expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0);
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
- await page.getByTestId("community-switcher").click();
-
const menu = page.getByRole("menu", { name: "Community actions" });
+ await communityTrigger.evaluate((trigger) => {
+ trigger.addEventListener(
+ "mouseenter",
+ () => {
+ trigger.dataset.hoverStartedAt = String(performance.now());
+ },
+ { once: true },
+ );
+ trigger.addEventListener("mouseleave", () => {
+ trigger.dataset.leftAt = String(performance.now());
+ });
+ const observer = new MutationObserver((records) => {
+ if (
+ trigger.getAttribute("aria-expanded") === "true" &&
+ !trigger.dataset.expandedAt
+ ) {
+ trigger.dataset.expandedAt = String(performance.now());
+ }
+ if (
+ records.some(
+ (record) =>
+ record.attributeName === "aria-expanded" &&
+ record.oldValue === "true",
+ )
+ ) {
+ trigger.dataset.closedAfterOpening = "true";
+ }
+ });
+ observer.observe(trigger, {
+ attributeFilter: ["aria-expanded"],
+ attributeOldValue: true,
+ attributes: true,
+ });
+ });
+ await communityTrigger.hover();
+ await expect(menu).toBeVisible({ timeout: 700 });
+ const openDelayMs = await communityTrigger.evaluate((trigger) => {
+ const hoverStartedAt = Number(trigger.dataset.hoverStartedAt);
+ const expandedAt = Number(trigger.dataset.expandedAt);
+ if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) {
+ throw new Error("Community actions open timing was not recorded");
+ }
+ return expandedAt - hoverStartedAt;
+ });
+ expect(openDelayMs).toBeGreaterThanOrEqual(40);
+ expect(openDelayMs).toBeLessThan(300);
+
+ const openTriggerBox = await communityTrigger.boundingBox();
+ const menuBox = await menu.boundingBox();
+ expect(openTriggerBox).not.toBeNull();
+ expect(menuBox).not.toBeNull();
+ if (!openTriggerBox || !menuBox) {
+ throw new Error("Community actions geometry unavailable");
+ }
+ const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1;
+ const triggerExitY = Math.min(
+ openTriggerBox.y + openTriggerBox.height - 4,
+ menuBox.y + menuBox.height - 4,
+ );
+ await page.mouse.move(triggerExitX, triggerExitY);
+ await page.mouse.move(menuBox.x + 8, menuBox.y - 8);
+ await page.waitForTimeout(80);
+ await page.mouse.move(menuBox.x + 8, menuBox.y + 8);
+ const bridgeDurationMs = await communityTrigger.evaluate((trigger) => {
+ const leftAt = Number(trigger.dataset.leftAt);
+ if (!Number.isFinite(leftAt)) {
+ throw new Error(
+ "Community actions trigger exit timing was not recorded",
+ );
+ }
+ return performance.now() - leftAt;
+ });
+ expect(bridgeDurationMs).toBeGreaterThanOrEqual(60);
+ expect(bridgeDurationMs).toBeLessThan(140);
+ await page.waitForTimeout(180);
await expect(menu).toBeVisible();
+ await expect(communityTrigger).not.toHaveAttribute(
+ "data-closed-after-opening",
+ "true",
+ );
await expect(
menu.getByRole("menuitem", { name: "Copy community URL" }),
).toBeVisible();
@@ -1133,7 +1231,36 @@ test.describe("community rail", () => {
).toBeVisible();
});
+ test("keeps the gutter when the mobile sidebar closes without a rail", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 740, height: 516 });
+ await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
+ await page.goto("/");
+
+ await page
+ .getByRole("button", { name: "Toggle Sidebar", exact: true })
+ .click();
+ await expect(
+ page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await expect(
+ page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
+ ).toBeHidden();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "width",
+ "8px",
+ );
+ await expectContentSurfaceHorizontalGutters(page, 9);
+ });
+
test("hides the rail with a single community", async ({ page }) => {
+ await page.addInitScript((themeStorageKey) => {
+ window.localStorage.setItem(themeStorageKey, "buzz-dark");
+ }, THEME_STORAGE_KEY);
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
await page.goto("/");
@@ -1142,6 +1269,25 @@ test.describe("community rail", () => {
// adds nothing).
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(page.getByTestId("community-rail")).toHaveCount(0);
+
+ await page
+ .getByRole("button", { name: "Toggle Sidebar", exact: true })
+ .click();
+ await expect(
+ page.locator('[data-side="left"][data-state="collapsed"]'),
+ ).toBeVisible();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "width",
+ "8px",
+ );
+ const sidebarBackground = await page
+ .locator("[data-buzz-glass-inset]")
+ .evaluate((element) => getComputedStyle(element).backgroundColor);
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "background-color",
+ sidebarBackground,
+ );
+ await expectContentSurfaceHorizontalGutters(page, 9);
});
test("keeps the rail visible when the sidebar is collapsed", async ({
@@ -1174,6 +1320,10 @@ test.describe("community rail", () => {
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
).toBeVisible();
await expect(page.getByTestId("community-rail-add")).toBeVisible();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount(
+ 0,
+ );
+ await expectContentSurfaceHorizontalGutters(page);
});
test("clears the macOS traffic lights", async ({ page }) => {
diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts
index 9b0bb051fc..d6cd05eb0f 100644
--- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts
+++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts
@@ -12,8 +12,8 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
-/** Hover the trigger, then slide the cursor onto the tooltip popup and
- * assert the tooltip dismisses instead of persisting. */
+/** Hover the trigger through the shared dwell, then slide the cursor onto the
+ * tooltip popup and assert the tooltip dismisses instead of persisting. */
async function expectTooltipDismissesOnLeave(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
@@ -22,7 +22,9 @@ async function expectTooltipDismissesOnLeave(
await trigger.hover();
const tip = page.getByRole("tooltip", { name: tooltipName });
- await expect(tip).toBeVisible();
+ await page.waitForTimeout(400);
+ await expect(tip).toHaveCount(0);
+ await expect(tip).toBeVisible({ timeout: 1_000 });
// Slide off the trigger onto the tooltip popup.
const box = await tip.boundingBox();
@@ -48,6 +50,26 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async
);
});
+test("adjacent composer tooltips each require a fresh dwell", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ await page.getByTestId("message-insert-mention").hover();
+ await page.waitForTimeout(400);
+ const mentionTooltip = page.getByRole("tooltip", { name: "Mention someone" });
+ await expect(mentionTooltip).toHaveCount(0);
+ await expect(mentionTooltip).toBeVisible({ timeout: 1_000 });
+
+ await page.getByRole("button", { name: "Attach file" }).hover();
+ await page.waitForTimeout(400);
+ const attachTooltip = page.getByRole("tooltip", { name: "Attach file" });
+ await expect(attachTooltip).toHaveCount(0);
+ await expect(attachTooltip).toBeVisible({ timeout: 1_000 });
+});
+
test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
@@ -60,6 +82,9 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger",
const bold = page.getByRole("button", { name: "Bold" });
await expect(bold).toBeVisible();
+ // The formatting strip animates into place; wait for its delayed entrance to
+ // settle so the pointer remains over the trigger for the full dwell.
+ await page.waitForTimeout(300);
// Tooltip text is " ()" for items that carry a shortcut.
await expectTooltipDismissesOnLeave(page, bold, "Bold (⌘B)");
diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts
index 75afe47f3b..b373092a81 100644
--- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts
+++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts
@@ -116,9 +116,11 @@ test("agent-style message with bare buzz:// links renders entity cards without s
await expect(
repoCard.locator("[data-link-preview-hostname-favicon]"),
).toHaveCount(0);
+ // Default typography is 14px; keep the image-less card compact while
+ // allowing fractional line-height rounding across rendering platforms.
expect(
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
- ).toBeLessThan(84);
+ ).toBeLessThan(90);
await waitForAnimations(page);
await page.screenshot({
diff --git a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts
index 15a804d71f..8f1f074e27 100644
--- a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts
+++ b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts
@@ -10,7 +10,7 @@
* tests/e2e/inbox-refactor-screenshots.spec.ts
* Output: test-results/inbox-refactor/
*/
-import { expect, test } from "@playwright/test";
+import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -24,11 +24,66 @@ const DM_CHANNEL_ID = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
+const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
+const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
// Fixed timestamps so draft ordering renders deterministically.
const DRAFT_CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const DRAFT_CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
+type FontSize = "smaller" | "default" | "larger";
+type ConversationDensity = "compact" | "comfortable" | "spacious";
+
+async function seedConversationPreferences(
+ page: Page,
+ fontSize: FontSize,
+ density: ConversationDensity,
+) {
+ await page.addInitScript(
+ ({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
+ window.localStorage.setItem(densityKey, densityValue);
+ window.localStorage.setItem(fontSizeKey, fontSizeValue);
+ },
+ {
+ densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
+ densityValue: density,
+ fontSizeKey: FONT_SIZE_STORAGE_KEY,
+ fontSizeValue: fontSize,
+ },
+ );
+}
+
+async function applyConversationPreferences(
+ page: Page,
+ fontSize: FontSize,
+ density: ConversationDensity,
+) {
+ await page.evaluate(
+ ({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
+ const update = (key: string, value: string) => {
+ const oldValue = window.localStorage.getItem(key);
+ window.localStorage.setItem(key, value);
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key,
+ newValue: value,
+ oldValue,
+ storageArea: window.localStorage,
+ }),
+ );
+ };
+ update(densityKey, densityValue);
+ update(fontSizeKey, fontSizeValue);
+ },
+ {
+ densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
+ densityValue: density,
+ fontSizeKey: FONT_SIZE_STORAGE_KEY,
+ fontSizeValue: fontSize,
+ },
+ );
+}
+
type MockFeedWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
@@ -273,6 +328,7 @@ test.describe("inbox refactor screenshots", () => {
});
test("04 — thread opens at the oldest unread reply", async ({ page }) => {
+ await seedConversationPreferences(page, "default", "comfortable");
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -341,6 +397,9 @@ test.describe("inbox refactor screenshots", () => {
const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`);
await expect(firstUnreadRow).toBeVisible();
+ const listPreview = firstUnreadRow.locator(".inbox-preview-markdown");
+ await expect(listPreview).toHaveCSS("font-size", "14px");
+ await expect(listPreview).toHaveCSS("line-height", "20px");
await firstUnreadRow.click();
const detail = page.getByTestId("home-inbox-detail");
@@ -352,8 +411,172 @@ test.describe("inbox refactor screenshots", () => {
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
"Started on the changelog — first pass is up.",
);
+
+ const selectedMessage = page.getByTestId("home-inbox-selected-message");
+ const selectedAuthor = selectedMessage.getByTestId("message-author");
+ const selectedBody = selectedMessage.locator(".message-markdown").first();
+ const selectedTimestamp = selectedMessage.getByTestId(
+ "inbox-message-timestamp",
+ );
+ const remainingListPreview = page
+ .locator("[data-testid^='home-inbox-item-']")
+ .locator(".inbox-preview-markdown")
+ .first();
+ const composerInput = page.getByTestId("message-input");
+ const readConversationMetrics = () =>
+ Promise.all([
+ remainingListPreview.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }),
+ selectedMessage.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ paddingBottom: style.paddingBottom,
+ paddingTop: style.paddingTop,
+ };
+ }),
+ selectedMessage.evaluate((element) => {
+ const header = element.querySelector(
+ "[data-testid='message-header']",
+ );
+ const body = element.querySelector(
+ "[data-testid='message-body']",
+ );
+ if (!header || !body) {
+ throw new Error("Inbox message spacing geometry is missing");
+ }
+ return (
+ body.getBoundingClientRect().top -
+ header.getBoundingClientRect().bottom
+ );
+ }),
+ selectedAuthor.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }),
+ selectedBody.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }),
+ selectedTimestamp.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }),
+ composerInput.evaluate((element) => {
+ const style = window.getComputedStyle(element);
+ return {
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }),
+ ]);
+ await expect
+ .poll(readConversationMetrics)
+ .toEqual([
+ { fontSize: "14px", lineHeight: "20px" },
+ { paddingBottom: "4px", paddingTop: "4px" },
+ 2,
+ { fontSize: "14px", lineHeight: "16px" },
+ { fontSize: "14px", lineHeight: "20px" },
+ { fontSize: "12px", lineHeight: "16px" },
+ { fontSize: "14px", lineHeight: "20px" },
+ ]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-thread-context.png` });
+
+ await applyConversationPreferences(page, "default", "compact");
+ await expect
+ .poll(readConversationMetrics)
+ .toEqual([
+ { fontSize: "14px", lineHeight: "20px" },
+ { paddingBottom: "4px", paddingTop: "4px" },
+ 0,
+ { fontSize: "14px", lineHeight: "16px" },
+ { fontSize: "14px", lineHeight: "20px" },
+ { fontSize: "12px", lineHeight: "16px" },
+ { fontSize: "14px", lineHeight: "20px" },
+ ]);
+
+ await applyConversationPreferences(page, "smaller", "compact");
+ await expect
+ .poll(readConversationMetrics)
+ .toEqual([
+ { fontSize: "13px", lineHeight: "18.5714px" },
+ { paddingBottom: "4px", paddingTop: "4px" },
+ 0,
+ { fontSize: "13px", lineHeight: "14.8571px" },
+ { fontSize: "13px", lineHeight: "18.5714px" },
+ { fontSize: "11.1429px", lineHeight: "14.8571px" },
+ { fontSize: "13px", lineHeight: "18.5714px" },
+ ]);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/05-thread-context-compact.png` });
+
+ await applyConversationPreferences(page, "larger", "spacious");
+ await expect
+ .poll(readConversationMetrics)
+ .toEqual([
+ { fontSize: "15px", lineHeight: "21.4286px" },
+ { paddingBottom: "8px", paddingTop: "8px" },
+ 4,
+ { fontSize: "15px", lineHeight: "17.1429px" },
+ { fontSize: "15px", lineHeight: "21.4286px" },
+ { fontSize: "12.8571px", lineHeight: "17.1429px" },
+ { fontSize: "15px", lineHeight: "21.4286px" },
+ ]);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/06-thread-context-spacious.png` });
+
+ await applyConversationPreferences(page, "default", "comfortable");
+
+ await page.evaluate(() => {
+ const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ code: "Equal",
+ ctrlKey: !isMac,
+ key: "+",
+ metaKey: isMac,
+ shiftKey: true,
+ }),
+ );
+ });
+
+ await expect
+ .poll(async () => [
+ await page.evaluate(() =>
+ window
+ .getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
+ ),
+ ...(await readConversationMetrics()),
+ ])
+ .toEqual([
+ "17.6px",
+ { fontSize: "15.4px", lineHeight: "22px" },
+ { paddingBottom: "4px", paddingTop: "4px" },
+ 2,
+ { fontSize: "15.4px", lineHeight: "17.6px" },
+ { fontSize: "15.4px", lineHeight: "22px" },
+ { fontSize: "13.2px", lineHeight: "17.6px" },
+ { fontSize: "15.4px", lineHeight: "22px" },
+ ]);
});
});
diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts
index 937fd57b47..731cce17e5 100644
--- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts
+++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts
@@ -304,9 +304,8 @@ test("pairing completion updates the final step and resets after leaving", async
const confirmButton = confirmation.getByTestId("confirm-sas");
const cancelButton = confirmation.getByTestId("deny-sas");
const confirmationBox = await confirmation.boundingBox();
- const confirmationTitleBox = await confirmation
- .getByTestId("pairing-sas-title")
- .boundingBox();
+ const confirmationTitle = confirmation.getByTestId("pairing-sas-title");
+ const confirmationTitleBox = await confirmationTitle.boundingBox();
const confirmationCodeBox = await confirmationCode.boundingBox();
const confirmationActionsBox = await confirmation
.getByTestId("pairing-sas-actions")
@@ -345,10 +344,7 @@ test("pairing completion updates the final step and resets after leaving", async
await expect(
confirmation.getByText(/Only confirm if you started this pairing/),
).toHaveCount(0);
- await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
- "font-size",
- "16px",
- );
+ await expect(confirmationTitle).toHaveCSS("font-size", "16px");
mkdirSync(SCREENSHOT_DIR, { recursive: true });
await waitForAnimations(page);
diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts
index 6c88b628ce..a58859ec55 100644
--- a/desktop/tests/e2e/profile.spec.ts
+++ b/desktop/tests/e2e/profile.spec.ts
@@ -2489,7 +2489,10 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
const getTextScaleState = () =>
page.evaluate(() => ({
- fontSize: getComputedStyle(document.documentElement).fontSize,
+ rootFontSize: getComputedStyle(document.documentElement).fontSize,
+ textRemSize: getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
storedScale: localStorage.getItem("buzz:text-scale"),
webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number })
.__BUZZ_E2E_WEBVIEW_ZOOM__,
@@ -2520,7 +2523,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("+", "Equal", true);
await expect.poll(getTextScaleState).toEqual({
- fontSize: "17.6px",
+ rootFontSize: "16px",
+ textRemSize: "17.6px",
storedScale: "1.1",
webviewZoom: 1,
});
@@ -2528,7 +2532,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("-", "Minus");
await expect.poll(getTextScaleState).toEqual({
- fontSize: "16px",
+ rootFontSize: "16px",
+ textRemSize: "16px",
storedScale: null,
webviewZoom: 1,
});
@@ -2537,7 +2542,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("+", "Equal", true);
await expect.poll(getTextScaleState).toEqual({
- fontSize: "19.2px",
+ rootFontSize: "16px",
+ textRemSize: "19.2px",
storedScale: "1.2",
webviewZoom: 1,
});
@@ -2545,12 +2551,95 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("0", "Digit0");
await expect.poll(getTextScaleState).toEqual({
- fontSize: "16px",
+ rootFontSize: "16px",
+ textRemSize: "16px",
storedScale: null,
webviewZoom: 1,
});
});
+test("storage clear resets composed font size and keyboard zoom across windows", async ({
+ context,
+ page,
+}) => {
+ await page.goto("/");
+ await openSettings(page, "appearance");
+ await page.getByTestId("font-size-larger").click();
+
+ const dispatchZoomIn = () =>
+ page.evaluate(() => {
+ const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ code: "Equal",
+ ctrlKey: !isMac,
+ key: "+",
+ metaKey: isMac,
+ shiftKey: true,
+ }),
+ );
+ });
+
+ for (let step = 0; step < 5; step += 1) {
+ await dispatchZoomIn();
+ }
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => ({
+ fontSize: document.documentElement.dataset.fontSize,
+ textRemSize: getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
+ textScale: localStorage.getItem("buzz:text-scale"),
+ })),
+ )
+ .toEqual({
+ fontSize: "larger",
+ textRemSize: "25.714286px",
+ textScale: "1.5",
+ });
+
+ const peerPage = await context.newPage();
+ await installMockBridge(peerPage);
+ await peerPage.goto("/");
+ await peerPage.evaluate(() => localStorage.clear());
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => ({
+ fontSize: document.documentElement.dataset.fontSize,
+ textRemSize: getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
+ textScale: localStorage.getItem("buzz:text-scale"),
+ })),
+ )
+ .toEqual({
+ fontSize: "default",
+ textRemSize: "16px",
+ textScale: null,
+ });
+
+ await page.keyboard.press(
+ process.platform === "darwin" ? "Meta+-" : "Control+-",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(() => ({
+ textRemSize: getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
+ textScale: localStorage.getItem("buzz:text-scale"),
+ })),
+ )
+ .toEqual({ textRemSize: "14.4px", textScale: "0.9" });
+
+ await peerPage.close();
+});
+
test("shows agent runtimes in agent settings", async ({ page }) => {
await page.goto("/");
diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts
index 74f819cc51..ef5812c9e8 100644
--- a/desktop/tests/e2e/relay-reconnect.spec.ts
+++ b/desktop/tests/e2e/relay-reconnect.spec.ts
@@ -80,10 +80,7 @@ async function getMockWebsocketConnectAttempts(
) {
return page.evaluate(() => {
const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__;
- if (!getAttempts) {
- throw new Error("E2E websocket attempt seam is not installed.");
- }
- return getAttempts();
+ return getAttempts?.() ?? [];
});
}
@@ -168,6 +165,52 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
+test("stalled early AUTH signing times out and starts a replacement dial", async ({
+ page,
+}) => {
+ test.setTimeout(40_000);
+ await installMockBridge(page, {
+ websocketAuthBeforeConnectResolves: true,
+ stallFirstAuthSigning: true,
+ });
+ await page.goto("/");
+
+ await expect
+ .poll(() => getMockWebsocketConnectAttempts(page), { timeout: 32_000 })
+ .toHaveLength(2);
+ await expect
+ .poll(
+ () =>
+ page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
+ { timeout: 5_000 },
+ )
+ .toBe("connected");
+});
+
+test("AUTH arriving before connect resolves does not lose the first send", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ websocketAuthBeforeConnectResolves: true,
+ });
+ await page.goto("/");
+ await expect
+ .poll(
+ () =>
+ page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
+ { timeout: 5_000 },
+ )
+ .toBe("connected");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const message = `first send after early auth ${Date.now()}`;
+ await page.getByTestId("message-input").fill(message);
+ await page.getByTestId("send-message").click();
+
+ await expect(page.getByTestId("message-timeline")).toContainText(message);
+});
+
test("failed initial relay dial retries automatically", async ({ page }) => {
await installMockBridge(page, {
websocketConnectErrors: ["mock relay pod unavailable"],
diff --git a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts
index 8bee8da659..df94afc699 100644
--- a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts
+++ b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/sidebar-offcanvas-rail";
const THEME_STORAGE_KEY = "buzz-theme";
@@ -20,7 +21,7 @@ const COMMUNITY_B = {
};
async function setup(page: Page, theme: string) {
- await page.setViewportSize({ width: 1280, height: 800 });
+ await page.setViewportSize({ width: 960, height: 540 });
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, value);
@@ -52,15 +53,95 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
page,
}) => {
await setup(page, theme);
+ await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` });
- await page.locator('[data-sidebar="trigger"]').first().click();
+ const communityRail = page.getByTestId("community-rail");
+ const communityButton = page.getByTestId(
+ `community-rail-button-${COMMUNITY_B.id}`,
+ );
+ const railBoxBeforeCollapse = await communityRail.boundingBox();
+ expect(railBoxBeforeCollapse).not.toBeNull();
+
+ // Observe the transition before triggering it, then hold every animated
+ // sidebar-content property at its midpoint. This keeps the regression
+ // causal without making its assertions depend on Playwright or rAF
+ // scheduler latency.
+ const transition = await communityButton.evaluate(async (button) => {
+ const rail = button.closest('[data-testid="community-rail"]');
+ const trigger = document.querySelector(
+ '[data-sidebar="trigger"]',
+ );
+ const sidebarContent = document.querySelector(
+ "[data-sidebar-transition-content]",
+ );
+ if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) {
+ return null;
+ }
+
+ const transitionStarted = new Promise((resolve) => {
+ sidebarContent.addEventListener("transitionrun", () => resolve(), {
+ once: true,
+ });
+ });
+ trigger.click();
+ await transitionStarted;
+
+ const animations = sidebarContent.getAnimations();
+ await Promise.all(animations.map((animation) => animation.ready));
+ for (const animation of animations) {
+ animation.pause();
+ animation.currentTime = 100;
+ }
+
+ const buttonBox = button.getBoundingClientRect();
+ const railBox = rail.getBoundingClientRect();
+ const hit = document.elementFromPoint(
+ buttonBox.x + buttonBox.width / 2,
+ buttonBox.y + buttonBox.height / 2,
+ );
+ const railStyle = getComputedStyle(rail);
+ const sidebarStyle = getComputedStyle(sidebarContent);
+ const result = {
+ durations: animations.map(
+ (animation) => animation.effect?.getTiming().duration,
+ ),
+ hitRail: hit === rail || rail.contains(hit),
+ opacity: railStyle.opacity,
+ sidebarOpacity: Number.parseFloat(sidebarStyle.opacity),
+ sidebarScale: sidebarStyle.scale,
+ sidebarTranslateX: Number.parseFloat(sidebarStyle.translate),
+ visibility: railStyle.visibility,
+ x: railBox.x,
+ y: railBox.y,
+ };
+
+ for (const animation of animations) animation.finish();
+ return result;
+ });
+ expect(transition).not.toBeNull();
+ expect(transition?.durations).toEqual([200, 200, 200]);
+ expect(transition).toMatchObject({
+ hitRail: true,
+ opacity: "1",
+ visibility: "visible",
+ x: railBoxBeforeCollapse?.x,
+ y: railBoxBeforeCollapse?.y,
+ });
+ expect(transition?.sidebarOpacity).toBeGreaterThan(0);
+ expect(transition?.sidebarOpacity).toBeLessThan(1);
+ expect(transition?.sidebarScale).not.toBe("none");
+ expect(transition?.sidebarScale).not.toBe("0.95");
+ expect(transition?.sidebarTranslateX).toBeGreaterThan(0);
+ expect(transition?.sidebarTranslateX).toBeLessThan(24);
+
const shell = page.locator(
'[data-state="collapsed"][data-collapsible="offcanvas"]',
);
await expect(shell).toHaveCount(1);
+
// Let the 200ms slide finish; visibility flips at the transition's end.
- await page.waitForTimeout(500);
+ await page.waitForTimeout(250);
// Second direct child = the sliding sidebar container (first is the gap).
const offscreenSidebar = shell.locator("> div").nth(1);
@@ -72,6 +153,7 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
await expect(
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
).toBeVisible();
+ await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` });
});
}
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index 7419499baf..01d9d64d67 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -531,6 +531,75 @@ test("aligns the sidebar search with the channel title outside the Buzz theme",
expect(Math.abs(searchCenter - channelTitleCenter)).toBeLessThanOrEqual(2);
});
+test("scales the sidebar backward while its chrome closes", async ({
+ page,
+}) => {
+ await page.goto("/");
+
+ const sidebar = page.getByTestId("app-sidebar");
+ const sidebarSurface = sidebar.locator("[data-sidebar-transition-content]");
+ await expect(sidebarSurface).toHaveCSS("opacity", "1");
+ await expect(sidebarSurface).toHaveCSS("scale", "none");
+
+ await page.getByRole("button", { name: "Toggle Sidebar" }).click();
+
+ await expect(sidebarSurface).toHaveCSS("opacity", "0");
+ await expect(sidebar).toHaveCSS("pointer-events", "none");
+ await expect(sidebar).toHaveCSS("overflow", "visible");
+ await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS(
+ "background-color",
+ await sidebarSurface.evaluate((element) => {
+ const sidebarElement = element.closest('[data-sidebar="sidebar"]');
+ if (!(sidebarElement instanceof HTMLElement)) return "";
+ return getComputedStyle(sidebarElement).backgroundColor;
+ }),
+ );
+ await expect(sidebarSurface).toHaveCSS("scale", "0.95");
+ await expect(sidebarSurface).toHaveCSS("translate", "24px");
+ const transformOrigin = await sidebarSurface.evaluate(
+ (element) => getComputedStyle(element).transformOrigin,
+ );
+ const [originX, originY] = transformOrigin.split(" ").map(Number.parseFloat);
+ const surfaceWidth = await sidebarSurface.evaluate(
+ (element) => element.clientWidth,
+ );
+ expect(Math.abs(originX - surfaceWidth / 2)).toBeLessThan(0.5);
+ expect(originY).toBe(0);
+ await expect(sidebarSurface).toHaveCSS(
+ "transition-property",
+ "opacity, scale, translate",
+ );
+ await expect(sidebarSurface).toHaveCSS("transition-duration", "0.2s");
+ await expect(sidebarSurface).toHaveCSS(
+ "transition-timing-function",
+ "linear",
+ );
+
+ await page.getByRole("button", { name: "Toggle Sidebar" }).click();
+ await expect(sidebarSurface).toHaveCSS("opacity", "1");
+ await expect(sidebar).toHaveCSS("pointer-events", "auto");
+ await expect(sidebarSurface).toHaveCSS("scale", "none");
+});
+
+test("disables the sidebar collapse transition for reduced motion", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto("/");
+
+ const sidebarSurface = page
+ .getByTestId("app-sidebar")
+ .locator("[data-sidebar-transition-content]");
+ await expect(sidebarSurface).toHaveCSS("transition-duration", "0s");
+
+ await page.getByRole("button", { name: "Toggle Sidebar" }).click();
+
+ await expect(sidebarSurface).toHaveCSS("opacity", "0");
+ await expect(sidebarSurface).toHaveCSS("scale", "0.95");
+ await expect(sidebarSurface).toHaveCSS("translate", "24px");
+ await expect(sidebarSurface).toHaveCSS("transition-duration", "0s");
+});
+
test("sidebar rail resizes without toggling the sidebar", async ({ page }) => {
await page.goto("/");
const rail = page.getByRole("button", { name: "Resize sidebar" });
diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index eb69953dbd..d26001ba6e 100644
--- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
+++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
@@ -23,7 +23,7 @@ const EXPECTED_NAV_CENTER_Y = 23;
// The macOS traffic lights are native chrome: with `trafficLightPosition`
// x:16 they occupy roughly x 16–68 regardless of the app's Cmd +/- text
// zoom. The top-chrome nav row must clear that band in fixed px, so the
-// clearance cannot shrink when the root font size scales down.
+// clearance cannot change when text scales.
const TRAFFIC_LIGHT_RIGHT_EDGE = 72;
async function spoofMacPlatform(page: import("@playwright/test").Page) {
@@ -90,15 +90,24 @@ async function seedTextScale(
}, scale);
}
-async function expectRootFontSize(
+async function expectTextRemSize(
page: import("@playwright/test").Page,
fontSize: string,
) {
await expect
.poll(() =>
- page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
+ page.evaluate(() =>
+ getComputedStyle(document.documentElement)
+ .getPropertyValue("--buzz-type-rem")
+ .trim(),
+ ),
)
.toBe(fontSize);
+ await expect
+ .poll(() =>
+ page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
+ )
+ .toBe("16px");
}
test.describe("top chrome macOS traffic-light clearance under text zoom", () => {
@@ -143,8 +152,8 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
await installMockBridge(page);
await page.goto("/");
- // Confirm the zoomed-out scale actually applied to the root font size.
- await expectRootFontSize(page, "12px");
+ // Confirm the zoomed-out text scale applied without changing the root.
+ await expectTextRemSize(page, "12px");
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
TRAFFIC_LIGHT_RIGHT_EDGE,
@@ -161,7 +170,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
await installMockBridge(page);
await page.goto("/");
- await expectRootFontSize(page, "24px");
+ await expectTextRemSize(page, "24px");
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
TRAFFIC_LIGHT_RIGHT_EDGE,
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 815c362fc1..dbe8c43323 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -354,6 +354,10 @@ type MockBridgeOptions = {
nostrBindSignDelayMs?: number;
/** Reject successive mock WebSocket connect attempts, then resume. */
websocketConnectErrors?: string[];
+ /** Deliver AUTH synchronously, before the mock connect command resolves. */
+ websocketAuthBeforeConnectResolves?: boolean;
+ /** Stall the first AUTH signing command forever; later attempts complete. */
+ stallFirstAuthSigning?: boolean;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — drive the archive-button gate matrix in
diff --git a/lefthook.yml b/lefthook.yml
index d3c8bddb83..b2b9f18894 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -28,17 +28,19 @@ pre-commit:
run: just desktop-tauri-fmt
stage_fixed: true
desktop-fix:
- glob: ["desktop/**", "pnpm-lock.yaml"]
+ # A lockfile-only change has no desktop source for Biome to rewrite.
+ glob: ["desktop/**"]
exclude: ["desktop/src-tauri/**"]
run: just desktop-fix
stage_fixed: true
web-fix:
- glob: ["web/**", "pnpm-lock.yaml"]
+ # A lockfile-only change has no web source for Biome to rewrite.
+ glob: ["web/**"]
run: just web-fix
stage_fixed: true
- mobile-fix:
+ mobile-fmt:
glob: ["mobile/**"]
- run: just mobile-fix
+ run: just mobile-fmt
stage_fixed: true
# Appends the DCO Signed-off-by trailer the required "DCO Check" enforces.
@@ -59,7 +61,7 @@ pre-push:
# would only duplicate policy and create another place for coverage drift.
run: just file-size-check
rust-tests:
- glob: ["crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"]
+ glob: ["crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "Justfile"]
run: just test-unit
desktop-check:
glob: ["desktop/**", "pnpm-lock.yaml"]
@@ -77,8 +79,11 @@ pre-push:
# Keep local lint parity with Desktop Core CI for every path that can
# affect the Tauri crate or its path dependencies. Run clippy and tests
# serially so parallel pre-push hooks do not contend for Cargo's lock.
- glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"]
+ glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "Justfile"]
run: just desktop-tauri-clippy && just desktop-tauri-test
+ mobile-check:
+ glob: ["mobile/**"]
+ run: just mobile-check
mobile-test:
glob: ["mobile/**"]
run: just mobile-test
diff --git a/mobile/README.md b/mobile/README.md
index 6a7f38bdf2..1849e1b097 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -41,6 +41,20 @@ files:
signing always win)
- `mobile/android/worktree.properties` (read by the debug build type only)
+Android developers can keep a stable local test identity that takes precedence
+over the generated worktree values by creating the gitignored
+`mobile/android/AppOverrides.properties`:
+
+```properties
+appName=Buzz Pairing
+applicationIdSuffix=.device_pairing_e2e1
+```
+
+These values are consumed by the debug build type only. The standard
+`just mobile-build-android` command can still be used; regenerating
+`worktree.properties` does not overwrite `AppOverrides.properties`. Release
+and profile builds keep the production `Buzz` name and application ID.
+
For direct Xcode / Android Studio / `flutter run` development, run
`./scripts/mobile-worktree-overrides.sh` from the repo root once per branch
switch to refresh the display label (the install identity never changes);
diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
index 32f598b0fe..c21f7b4e86 100644
--- a/mobile/android/.gitignore
+++ b/mobile/android/.gitignore
@@ -7,6 +7,7 @@ gradle-wrapper.jar
GeneratedPluginRegistrant.java
.cxx/
/worktree.properties
+/AppOverrides.properties
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
index ca40ee16c6..091ae65262 100644
--- a/mobile/android/app/build.gradle.kts
+++ b/mobile/android/app/build.gradle.kts
@@ -30,6 +30,15 @@ val worktreeProps =
Properties().apply {
if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) }
}
+// Optional gitignored developer overrides are loaded after the generated
+// worktree values. They are consumed only by the debug build type below, so a
+// long-lived device test build can keep a stable, descriptive local identity
+// without changing release/profile or being overwritten by the worktree script.
+val appOverridesFile = rootProject.file("AppOverrides.properties")
+val appOverrides =
+ Properties().apply {
+ if (appOverridesFile.isFile) appOverridesFile.inputStream().use { load(it) }
+ }
val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() }
if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) {
throw GradleException(
@@ -39,10 +48,22 @@ if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+""")
}
val worktreeIdSuffix =
worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
-if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
+val debugIdSuffix =
+ appOverrides.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
+ ?: worktreeIdSuffix
+if (debugIdSuffix != null && !debugIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
throw GradleException(
- "worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
- worktreeIdSuffix,
+ "debug applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
+ debugIdSuffix,
+ )
+}
+val debugAppName = appOverrides.getProperty("appName")?.takeIf { it.isNotBlank() }
+if (
+ debugAppName != null &&
+ !debugAppName.matches(Regex("""[A-Za-z0-9][A-Za-z0-9 ._()\-]{0,39}"""))
+) {
+ throw GradleException(
+ "debug appName must be 1-40 resource-safe characters, got: " + debugAppName,
)
}
@@ -109,10 +130,12 @@ android {
debug {
// Only debug builds take the worktree identity; release/profile
// keep the production applicationId and label.
- if (worktreeIdSuffix != null) {
- applicationIdSuffix = worktreeIdSuffix
+ if (debugIdSuffix != null) {
+ applicationIdSuffix = debugIdSuffix
}
- if (worktreeLabel != null) {
+ if (debugAppName != null) {
+ resValue("string", "app_name", debugAppName)
+ } else if (worktreeLabel != null) {
resValue("string", "app_name", "Buzz ($worktreeLabel)")
}
}
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 45cf085cb7..f6f66ee2dc 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -16,6 +16,10 @@
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; };
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; };
+ 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */; };
+ 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */; };
+ 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */; };
+ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -63,6 +67,10 @@
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; };
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; };
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; };
+ 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; };
+ 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = ""; };
+ 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = ""; };
+ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -188,6 +196,10 @@
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
+ 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */,
+ 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */,
+ 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */,
+ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -425,6 +437,10 @@
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,
+ 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */,
+ 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */,
+ 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */,
+ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index fa68e539dd..e4ee6dbd91 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -10,6 +10,8 @@ import UserNotifications
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
+ private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
+ private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -113,6 +115,34 @@ import UserNotifications
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
+
+ let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzNativeEmojiPicker"
+ )
+ nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator(
+ messenger: messenger,
+ parentViewController: nativeEmojiPickerRegistrar?.viewController
+ )
+ if #available(iOS 16.0, *),
+ let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzNativeMessageActionSurface"
+ ) {
+ nativeMessageActionsRegistrar.register(
+ NativeMessageActionSurfaceFactory(messenger: messenger),
+ withId: "buzz/native_message_action_surface"
+ )
+ nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
+ name: "buzz/native_message_action_surface",
+ binaryMessenger: messenger
+ )
+ nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
+ guard call.method == "isSupported" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ result(true)
+ }
+ }
}
private static func handleQrScannerMethodCall(
diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift
new file mode 100644
index 0000000000..3109923dab
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPicker.swift
@@ -0,0 +1,193 @@
+import Flutter
+import SwiftUI
+import UIKit
+
+private struct NativeEmojiMediaHeaderError: Error {}
+
+final class NativeEmojiPickerCoordinator: NSObject,
+ UIAdaptivePresentationControllerDelegate
+{
+ private let channel: FlutterMethodChannel
+ private static weak var activeCoordinator: NativeEmojiPickerCoordinator?
+ private weak var parentViewController: UIViewController?
+ private weak var presentedController: UIViewController?
+ private var didNotifyDismissal = false
+ private var isDismissing = false
+
+ init(
+ messenger: FlutterBinaryMessenger,
+ parentViewController: UIViewController?
+ ) {
+ channel = FlutterMethodChannel(
+ name: "buzz/native_emoji_picker",
+ binaryMessenger: messenger
+ )
+ self.parentViewController = parentViewController
+ super.init()
+ Self.activeCoordinator = self
+ channel.setMethodCallHandler { [weak self] call, result in
+ self?.handle(call, result: result)
+ }
+ }
+
+ static func mediaHeaders(for url: URL) async throws -> [String: String] {
+ guard let channel = activeCoordinator?.channel else { return [:] }
+ return try await withCheckedThrowingContinuation { continuation in
+ channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in
+ if result is FlutterError {
+ continuation.resume(throwing: NativeEmojiMediaHeaderError())
+ return
+ }
+ continuation.resume(returning: result as? [String: String] ?? [:])
+ }
+ }
+ }
+
+ private func handle(
+ _ call: FlutterMethodCall,
+ result: @escaping FlutterResult
+ ) {
+ guard call.method == "present" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ guard let arguments = call.arguments as? [String: Any] else {
+ result(
+ FlutterError(
+ code: "invalid_arguments",
+ message: "Expected emoji-picker configuration.",
+ details: nil
+ )
+ )
+ return
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ result(self?.present(arguments: arguments) ?? false)
+ }
+ }
+
+ @MainActor
+ private func present(arguments: [String: Any]) -> Bool {
+ // A sheet is already owned by an earlier caller; report busy rather than a
+ // successful presentation so the caller does not treat this as its own.
+ guard presentedController == nil else { return false }
+ guard
+ let data = NativeEmojiPickerDataLoader.load(arguments: arguments),
+ let presenter = topViewController(
+ from: parentViewController ?? activeWindowRootViewController()
+ )
+ else {
+ return false
+ }
+
+ didNotifyDismissal = false
+ isDismissing = false
+ let appearance = NativeEmojiPickerAppearance(arguments: arguments)
+ let content = NativeEmojiPickerView(
+ data: data,
+ appearance: appearance,
+ initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0,
+ onSelect: { [weak self] emoji in self?.select(emoji) },
+ onSkinToneChanged: { [weak self] value in
+ self?.channel.invokeMethod("skinToneChanged", arguments: value)
+ },
+ onClose: { [weak self] in self?.dismiss() }
+ )
+ let controller = UIHostingController(rootView: content)
+ controller.view.backgroundColor = appearance.surface
+ controller.modalPresentationStyle = .pageSheet
+ controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light
+
+ if let sheet = controller.sheetPresentationController {
+ let compactID = UISheetPresentationController.Detent.Identifier(
+ "buzz.emoji.compact"
+ )
+ let mediumID = UISheetPresentationController.Detent.Identifier(
+ "buzz.emoji.medium"
+ )
+ sheet.detents = [
+ .custom(identifier: compactID) { context in
+ context.maximumDetentValue * 0.34
+ },
+ .custom(identifier: mediumID) { context in
+ context.maximumDetentValue * 0.67
+ },
+ .large(),
+ ]
+ sheet.selectedDetentIdentifier = mediumID
+ sheet.prefersGrabberVisible = true
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = false
+ sheet.prefersEdgeAttachedInCompactHeight = false
+ sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
+ }
+
+ presentedController = controller
+ presenter.present(controller, animated: true) { [weak self, weak controller] in
+ controller?.presentationController?.delegate = self
+ }
+ return true
+ }
+
+ @MainActor
+ private func select(_ emoji: String) {
+ // A single presentation returns at most one selection. The sheet stays
+ // live through its dismissal animation, so ignore extra taps that arrive
+ // before dismissal completes to avoid emitting duplicate selections.
+ guard !isDismissing else { return }
+ channel.invokeMethod("selected", arguments: emoji)
+ dismiss()
+ }
+
+ @MainActor
+ private func dismiss() {
+ isDismissing = true
+ guard let controller = presentedController else {
+ notifyDismissalIfNeeded()
+ return
+ }
+ controller.dismiss(animated: true) { [weak self] in
+ self?.notifyDismissalIfNeeded()
+ }
+ }
+
+ func presentationControllerDidDismiss(
+ _ presentationController: UIPresentationController
+ ) {
+ notifyDismissalIfNeeded()
+ }
+
+ @MainActor
+ private func notifyDismissalIfNeeded() {
+ guard !didNotifyDismissal else { return }
+ didNotifyDismissal = true
+ presentedController = nil
+ channel.invokeMethod("dismissed", arguments: nil)
+ }
+
+ @MainActor
+ private func activeWindowRootViewController() -> UIViewController? {
+ UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .filter { $0.activationState == .foregroundActive }
+ .flatMap(\.windows)
+ .first(where: \.isKeyWindow)?
+ .rootViewController
+ }
+
+ @MainActor
+ private func topViewController(
+ from viewController: UIViewController?
+ ) -> UIViewController? {
+ if let presented = viewController?.presentedViewController {
+ return topViewController(from: presented)
+ }
+ if let navigation = viewController as? UINavigationController {
+ return topViewController(from: navigation.visibleViewController)
+ }
+ if let tab = viewController as? UITabBarController {
+ return topViewController(from: tab.selectedViewController)
+ }
+ return viewController
+ }
+}
diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift
new file mode 100644
index 0000000000..75bf46446c
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift
@@ -0,0 +1,437 @@
+import Flutter
+import SwiftUI
+import UIKit
+
+struct NativeEmojiPickerAppearance {
+ let surface: UIColor
+ let control: UIColor
+ let text: UIColor
+ let secondaryText: UIColor
+ let accent: UIColor
+ let divider: UIColor
+ let isDark: Bool
+
+ init(arguments: [String: Any]) {
+ surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground)
+ control = Self.color(
+ arguments["controlColor"],
+ fallback: .secondarySystemBackground
+ )
+ text = Self.color(arguments["textColor"], fallback: .label)
+ secondaryText = Self.color(
+ arguments["secondaryTextColor"],
+ fallback: .secondaryLabel
+ )
+ accent = Self.color(arguments["accentColor"], fallback: .systemBlue)
+ divider = Self.color(arguments["dividerColor"], fallback: .separator)
+ isDark = arguments["isDark"] as? Bool ?? false
+ }
+
+ private static func color(_ raw: Any?, fallback: UIColor) -> UIColor {
+ guard let value = (raw as? NSNumber)?.uint32Value else { return fallback }
+ let alpha = CGFloat((value >> 24) & 0xFF) / 255
+ let red = CGFloat((value >> 16) & 0xFF) / 255
+ let green = CGFloat((value >> 8) & 0xFF) / 255
+ let blue = CGFloat(value & 0xFF) / 255
+ return UIColor(red: red, green: green, blue: blue, alpha: alpha)
+ }
+}
+
+struct NativeEmojiItem: Identifiable, Hashable {
+ let id: String
+ let shortcode: String
+ let value: String
+ let name: String
+ let keywords: [String]
+ let glyph: String?
+ let skinVariants: [String]
+ let imageURL: URL?
+}
+
+struct NativeEmojiSkinTone: Identifiable {
+ let id: Int
+ let label: String
+ let color: UIColor
+}
+
+let nativeEmojiSkinTones = [
+ NativeEmojiSkinTone(
+ id: 0,
+ label: "Default",
+ color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 1,
+ label: "Light",
+ color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 2,
+ label: "Medium-light",
+ color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 3,
+ label: "Medium",
+ color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 4,
+ label: "Medium-dark",
+ color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 5,
+ label: "Dark",
+ color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1)
+ ),
+]
+
+func validNativeEmojiSkinTone(_ value: Int) -> Int {
+ nativeEmojiSkinTones.indices.contains(value) ? value : 0
+}
+
+struct NativeEmojiSection: Identifiable {
+ let id: String
+ let title: String
+ let systemImage: String
+ let items: [NativeEmojiItem]
+}
+
+struct NativeEmojiPickerData {
+ let sections: [NativeEmojiSection]
+ let standardItems: [NativeEmojiItem]
+ let customItems: [NativeEmojiItem]
+}
+
+enum NativeEmojiPickerDataLoader {
+ static let assetPath = "assets/emoji/emoji-data.json"
+
+ static func load(arguments: [String: Any]) -> NativeEmojiPickerData? {
+ let key = FlutterDartProject.lookupKey(forAsset: assetPath)
+ let url = Bundle.main.bundleURL.appendingPathComponent(key)
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ return parse(data: data, arguments: arguments)
+ }
+
+ static func parse(
+ data: Data,
+ arguments: [String: Any]
+ ) -> NativeEmojiPickerData? {
+ guard
+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let rawCategories = root["categories"] as? [[String: Any]],
+ let rawEmoji = root["emoji"] as? [String: Any]
+ else {
+ return nil
+ }
+
+ var sections: [NativeEmojiSection] = []
+ var standardItems: [NativeEmojiItem] = []
+ var byValue: [String: NativeEmojiItem] = [:]
+
+ for category in rawCategories {
+ guard
+ let categoryID = category["id"] as? String,
+ let emojiIDs = category["emoji"] as? [String]
+ else {
+ continue
+ }
+
+ var items: [NativeEmojiItem] = []
+ for emojiID in emojiIDs {
+ guard let record = rawEmoji[emojiID] as? [String: Any] else { continue }
+ let name = record["n"] as? String ?? emojiID
+ let keywords = record["k"] as? [String] ?? []
+ let glyphs: [String]
+ if let values = record["u"] as? [String] {
+ glyphs = values
+ } else if let value = record["u"] as? String {
+ glyphs = [value]
+ } else {
+ glyphs = []
+ }
+
+ guard let defaultGlyph = glyphs.first else { continue }
+ let item = NativeEmojiItem(
+ id: emojiID,
+ shortcode: emojiID,
+ value: defaultGlyph,
+ name: name,
+ keywords: keywords,
+ glyph: defaultGlyph,
+ skinVariants: glyphs,
+ imageURL: nil
+ )
+ items.append(item)
+ standardItems.append(item)
+ for glyph in glyphs where byValue[glyph] == nil {
+ byValue[glyph] = item
+ }
+ }
+
+ sections.append(
+ NativeEmojiSection(
+ id: categoryID,
+ title: categoryTitle(categoryID),
+ systemImage: categorySymbol(categoryID),
+ items: items
+ )
+ )
+ }
+
+ let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? []
+ let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in
+ guard
+ let shortcode = raw["shortcode"] as? String,
+ let urlString = raw["url"] as? String,
+ let url = URL(string: urlString)
+ else {
+ return nil
+ }
+ return NativeEmojiItem(
+ id: "custom-\(shortcode)",
+ shortcode: shortcode,
+ value: ":\(shortcode):",
+ name: shortcode,
+ keywords: [],
+ glyph: nil,
+ skinVariants: [],
+ imageURL: url
+ )
+ }
+ let customByValue = Dictionary(
+ customItems.map { ($0.value, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+
+ let recentValues = arguments["recent"] as? [String] ?? []
+ var seenRecentIDs: Set = []
+ let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in
+ guard let item = byValue[value] ?? customByValue[value] else { return nil }
+ return seenRecentIDs.insert(item.id).inserted ? item : nil
+ }
+ if !recentItems.isEmpty {
+ sections.insert(
+ NativeEmojiSection(
+ id: "frequent",
+ title: "Frequently used",
+ systemImage: "clock",
+ items: recentItems
+ ),
+ at: 0
+ )
+ }
+
+ if !customItems.isEmpty {
+ sections.append(
+ NativeEmojiSection(
+ id: "custom",
+ title: "Custom",
+ systemImage: "sparkles",
+ items: customItems
+ )
+ )
+ }
+
+ return NativeEmojiPickerData(
+ sections: sections,
+ standardItems: standardItems,
+ customItems: customItems
+ )
+ }
+
+ private static func categoryTitle(_ id: String) -> String {
+ switch id {
+ case "people": return "Smileys & People"
+ case "nature": return "Animals & Nature"
+ case "foods": return "Food & Drink"
+ case "activity": return "Activity"
+ case "places": return "Travel & Places"
+ case "objects": return "Objects"
+ case "symbols": return "Symbols"
+ case "flags": return "Flags"
+ default: return id.capitalized
+ }
+ }
+
+ private static func categorySymbol(_ id: String) -> String {
+ switch id {
+ case "people": return "face.smiling"
+ case "nature": return "leaf"
+ case "foods": return "fork.knife"
+ case "activity": return "figure.run"
+ case "places": return "airplane"
+ case "objects": return "lightbulb"
+ case "symbols": return "heart"
+ case "flags": return "flag"
+ default: return "circle.grid.3x3"
+ }
+ }
+}
+
+private struct NativeEmojiSearchScore: Comparable {
+ let tier: Int
+ let detail: Int
+ let length: Int
+ let code: String
+
+ static func < (lhs: Self, rhs: Self) -> Bool {
+ if lhs.tier != rhs.tier { return lhs.tier < rhs.tier }
+ if lhs.detail != rhs.detail { return lhs.detail < rhs.detail }
+ if lhs.length != rhs.length { return lhs.length < rhs.length }
+ return lhs.code < rhs.code
+ }
+}
+
+enum NativeEmojiSearch {
+ static func results(
+ query: String,
+ items: [NativeEmojiItem]
+ ) -> [NativeEmojiItem] {
+ items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in
+ guard let score = score(query: query, item: item) else { return nil }
+ return (item, score)
+ }
+ .sorted { $0.1 < $1.1 }
+ .map(\.0)
+ }
+
+ private static func score(
+ query: String,
+ item: NativeEmojiItem
+ ) -> NativeEmojiSearchScore? {
+ let normalizedQuery = collapse(query)
+ guard !normalizedQuery.isEmpty else { return nil }
+ let code = item.shortcode.lowercased()
+ let normalizedCode = collapse(code)
+
+ if normalizedCode == normalizedQuery {
+ return makeScore(tier: 0, detail: 0, code: code)
+ }
+ if normalizedCode.hasPrefix(normalizedQuery) {
+ return makeScore(tier: 1, detail: 0, code: code)
+ }
+
+ let words = ([item.name] + item.keywords)
+ .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) }
+ .map(String.init)
+ if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) {
+ return makeScore(tier: 2, detail: index, code: code)
+ }
+ if let range = normalizedCode.range(of: normalizedQuery) {
+ return makeScore(
+ tier: 3,
+ detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound),
+ code: code
+ )
+ }
+ if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) {
+ return makeScore(tier: 4, detail: index, code: code)
+ }
+ if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) {
+ return makeScore(tier: 5, detail: span, code: code)
+ }
+ return nil
+ }
+
+ private static func makeScore(
+ tier: Int,
+ detail: Int,
+ code: String
+ ) -> NativeEmojiSearchScore {
+ NativeEmojiSearchScore(
+ tier: tier,
+ detail: detail,
+ length: code.count,
+ code: code
+ )
+ }
+
+ private static func collapse(_ value: String) -> String {
+ value.lowercased().filter { !":_ -\t\n".contains($0) }
+ }
+
+ private static func subsequenceSpan(_ query: String, in target: String) -> Int? {
+ let queryCharacters = Array(query)
+ guard !queryCharacters.isEmpty else { return nil }
+ var queryIndex = 0
+ var first: Int?
+ var last = 0
+ for (targetIndex, character) in target.enumerated() {
+ guard character == queryCharacters[queryIndex] else { continue }
+ if first == nil { first = targetIndex }
+ last = targetIndex
+ queryIndex += 1
+ if queryIndex == queryCharacters.count {
+ return last - (first ?? last)
+ }
+ }
+ return nil
+ }
+}
+
+/// The top offset of each pinned section header, keyed by section id, reported
+/// up from the scrolling grid so the rail can follow manual scrolling.
+///
+/// The same stream also carries two viewport measurements under the reserved
+/// keys below, so the tracker sees the section offsets and the viewport bounds
+/// consistently in a single update. Section ids come from the emoji dataset and
+/// never collide with these dotted reserved keys.
+let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom"
+let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom"
+
+struct NativeEmojiSectionOffsetsKey: PreferenceKey {
+ static let defaultValue: [String: CGFloat] = [:]
+
+ static func reduce(
+ value: inout [String: CGFloat],
+ nextValue: () -> [String: CGFloat]
+ ) {
+ value.merge(nextValue(), uniquingKeysWith: { _, next in next })
+ }
+}
+
+/// Pure selection logic: the highlighted section is the last one whose header
+/// has scrolled to or above the top of the viewport. Extracted so the
+/// scroll-tracking behaviour can be unit-tested without a live scroll view.
+enum NativeEmojiCategoryTracker {
+ static func selectedSectionID(
+ order: [String],
+ offsets: [String: CGFloat],
+ viewportTop: CGFloat,
+ viewportBottom: CGFloat? = nil,
+ contentBottom: CGFloat? = nil
+ ) -> String? {
+ // At the clamped bottom of an overflowing list, a final section shorter
+ // than the viewport can never scroll its header to the top, so the
+ // header-at-top rule would keep the preceding section highlighted while the
+ // user is plainly viewing the last one. Detect that case first: the content
+ // end is on screen (`contentBottom <= viewportBottom`) and the top has
+ // scrolled away (`firstTop < viewportTop`, so the list really did overflow
+ // rather than merely fitting). Highlight the last section then.
+ if let viewportBottom,
+ let contentBottom,
+ contentBottom <= viewportBottom + 1,
+ let firstID = order.first,
+ let firstTop = offsets[firstID],
+ firstTop < viewportTop,
+ let lastID = order.last
+ {
+ return lastID
+ }
+
+ var selected: String?
+ for id in order {
+ guard let top = offsets[id] else { continue }
+ // A small tolerance keeps the header that is flush with the top pinned as
+ // selected rather than flickering to the next section.
+ if top <= viewportTop + 1 {
+ selected = id
+ } else {
+ break
+ }
+ }
+ return selected ?? order.first
+ }
+}
diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift
new file mode 100644
index 0000000000..a71de4b815
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPickerView.swift
@@ -0,0 +1,596 @@
+import ImageIO
+import SwiftUI
+import UIKit
+
+struct NativeEmojiPickerView: View {
+ let data: NativeEmojiPickerData
+ let appearance: NativeEmojiPickerAppearance
+ let onSelect: (String) -> Void
+ let onSkinToneChanged: (Int) -> Void
+ let onClose: () -> Void
+
+ @State private var query = ""
+ @State private var selectedSectionID: String?
+ @State private var selectedSkinTone: Int
+
+ private let columns = Array(
+ repeating: GridItem(.flexible(minimum: 36), spacing: 0),
+ count: 8
+ )
+
+ private let sectionListSpace = "buzz.emoji.sectionList"
+
+ init(
+ data: NativeEmojiPickerData,
+ appearance: NativeEmojiPickerAppearance,
+ initialSkinTone: Int,
+ onSelect: @escaping (String) -> Void,
+ onSkinToneChanged: @escaping (Int) -> Void,
+ onClose: @escaping () -> Void
+ ) {
+ self.data = data
+ self.appearance = appearance
+ self.onSelect = onSelect
+ self.onSkinToneChanged = onSkinToneChanged
+ self.onClose = onClose
+ _selectedSkinTone = State(
+ initialValue: validNativeEmojiSkinTone(initialSkinTone)
+ )
+ }
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ VStack(spacing: 0) {
+ header
+ if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ categoryRail(proxy)
+ }
+ Divider().overlay(Color(uiColor: appearance.divider))
+ pickerContent
+ }
+ .background(Color(uiColor: appearance.surface))
+ .onAppear {
+ selectedSectionID = data.sections.first?.id
+ }
+ }
+ }
+
+ private var header: some View {
+ HStack(spacing: 8) {
+ HStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 17, weight: .medium))
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ TextField("Search emoji", text: $query)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled(true)
+ .submitLabel(.search)
+ .foregroundStyle(Color(uiColor: appearance.text))
+ if !query.isEmpty {
+ Button {
+ query = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Clear search")
+ }
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 44)
+ .background(Color(uiColor: appearance.control), in: Capsule())
+ .overlay {
+ Capsule()
+ .stroke(Color(uiColor: appearance.divider), lineWidth: 1)
+ }
+
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(Color(uiColor: appearance.text))
+ .frame(width: 44, height: 44)
+ .background(Color(uiColor: appearance.control), in: Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Close sheet")
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 16)
+ .padding(.bottom, 8)
+ }
+
+ private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
+ HStack(spacing: 0) {
+ ForEach(data.sections) { section in
+ Button {
+ selectedSectionID = section.id
+ withAnimation(.easeOut(duration: 0.24)) {
+ proxy.scrollTo("section-\(section.id)", anchor: .top)
+ }
+ } label: {
+ Image(systemName: section.systemImage)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(
+ Color(
+ uiColor: selectedSectionID == section.id
+ ? appearance.accent : appearance.secondaryText
+ )
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: 36)
+ .background(
+ selectedSectionID == section.id
+ ? Color(uiColor: appearance.control) : Color.clear,
+ in: Circle()
+ )
+ }
+ .frame(maxWidth: .infinity)
+ .buttonStyle(.plain)
+ .accessibilityLabel(section.title)
+ .accessibilityAddTraits(
+ selectedSectionID == section.id ? .isSelected : []
+ )
+ }
+ Divider()
+ .frame(height: 24)
+ .overlay(Color(uiColor: appearance.divider))
+ skinToneSelector
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 44)
+ }
+
+ private var skinToneSelector: some View {
+ Menu {
+ ForEach(nativeEmojiSkinTones) { tone in
+ Button {
+ selectedSkinTone = tone.id
+ onSkinToneChanged(tone.id)
+ } label: {
+ Label {
+ Text(tone.label)
+ } icon: {
+ Image(uiImage: skinTonePreviewImage(tone))
+ .renderingMode(.original)
+ }
+ }
+ }
+ } label: {
+ skinToneDot(nativeEmojiSkinTones[selectedSkinTone])
+ .frame(maxWidth: .infinity)
+ .frame(height: 36)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Skin tone")
+ }
+
+ private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View {
+ Circle()
+ .fill(Color(uiColor: tone.color))
+ .frame(width: 16, height: 16)
+ .overlay {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.white.opacity(0.2), .clear],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ .blendMode(.overlay)
+ }
+ .overlay {
+ Circle().stroke(.black.opacity(0.8), lineWidth: 1)
+ }
+ }
+
+ private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage {
+ let size = CGSize(width: 16, height: 16)
+ return UIGraphicsImageRenderer(size: size).image { rendererContext in
+ let context = rendererContext.cgContext
+ let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
+ let circle = UIBezierPath(ovalIn: rect)
+
+ tone.color.setFill()
+ circle.fill()
+
+ if let gradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: [
+ UIColor.white.withAlphaComponent(0.2).cgColor,
+ UIColor.clear.cgColor,
+ ] as CFArray,
+ locations: [0, 1]
+ ) {
+ context.saveGState()
+ circle.addClip()
+ context.setBlendMode(.overlay)
+ context.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: size.width / 2, y: 0),
+ end: CGPoint(x: size.width / 2, y: size.height),
+ options: []
+ )
+ context.restoreGState()
+ }
+
+ UIColor.black.withAlphaComponent(0.8).setStroke()
+ circle.lineWidth = 1
+ circle.stroke()
+ }
+ }
+
+ @ViewBuilder
+ private var pickerContent: some View {
+ let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedQuery.isEmpty {
+ sectionList(data.sections, tracksSelection: true)
+ } else {
+ let custom = NativeEmojiSearch.results(
+ query: trimmedQuery,
+ items: data.customItems
+ )
+ let standard = NativeEmojiSearch.results(
+ query: trimmedQuery,
+ items: data.standardItems
+ )
+ let sections = [
+ NativeEmojiSection(
+ id: "search-custom",
+ title: "Custom",
+ systemImage: "sparkles",
+ items: custom
+ ),
+ NativeEmojiSection(
+ id: "search-standard",
+ title: "Emoji",
+ systemImage: "face.smiling",
+ items: standard
+ ),
+ ].filter { !$0.items.isEmpty }
+
+ if sections.isEmpty {
+ VStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 28))
+ Text("No emoji found").font(.body)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ } else {
+ sectionList(sections, tracksSelection: false)
+ }
+ }
+ }
+
+ private func sectionList(
+ _ sections: [NativeEmojiSection],
+ tracksSelection: Bool
+ ) -> some View {
+ ScrollView {
+ LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
+ ForEach(sections) { section in
+ Section {
+ LazyVGrid(columns: columns, spacing: 0) {
+ ForEach(section.items) { item in
+ emojiButton(item)
+ }
+ }
+ .padding(.horizontal, 16)
+ } header: {
+ HStack {
+ Text(section.title)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 30)
+ .background(Color(uiColor: appearance.surface))
+ .background(sectionOffsetReporter(id: section.id))
+ .id("section-\(section.id)")
+ }
+ }
+ }
+ .padding(.bottom, 8)
+ .background(contentBoundaryReporter())
+ }
+ .coordinateSpace(name: sectionListSpace)
+ .background(viewportBoundaryReporter())
+ .scrollDismissesKeyboard(.interactively)
+ .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
+ guard tracksSelection else { return }
+ selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
+ order: data.sections.map(\.id),
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: offsets[nativeEmojiViewportBottomKey],
+ contentBottom: offsets[nativeEmojiContentBottomKey]
+ )
+ }
+ }
+
+ private func sectionOffsetReporter(id: String) -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
+ )
+ }
+ }
+
+ // The end of the scrolling content, relative to the viewport top. At the
+ // clamped bottom of an overflowing list this converges on the viewport
+ // height, which lets the tracker highlight a short final section that can
+ // never scroll its own header to the top.
+ private func contentBoundaryReporter() -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [
+ nativeEmojiContentBottomKey:
+ geometry.frame(in: .named(sectionListSpace)).maxY
+ ]
+ )
+ }
+ }
+
+ // The fixed viewport height, reported through the same preference stream so
+ // it stays consistent with the section offsets in each update.
+ private func viewportBoundaryReporter() -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [nativeEmojiViewportBottomKey: geometry.size.height]
+ )
+ }
+ }
+
+ private func emojiButton(_ item: NativeEmojiItem) -> some View {
+ let value = displayValue(for: item)
+ return Button {
+ onSelect(value)
+ } label: {
+ Group {
+ if let url = item.imageURL {
+ NativeEmojiRemoteImage(
+ url: url,
+ fallbackColor: appearance.secondaryText
+ )
+ .frame(width: 28, height: 28)
+ } else {
+ Text(value).font(.system(size: 28))
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 44)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(item.name)
+ }
+
+ private func displayValue(for item: NativeEmojiItem) -> String {
+ guard item.imageURL == nil else { return item.value }
+ guard item.skinVariants.indices.contains(selectedSkinTone) else {
+ return item.skinVariants.first ?? item.value
+ }
+ return item.skinVariants[selectedSkinTone]
+ }
+}
+
+struct NativeEmojiRemoteImage: View {
+ let url: URL
+ let fallbackColor: UIColor
+
+ @State private var phase: Phase = .loading
+
+ private enum Phase {
+ case loading
+ case success(UIImage)
+ case failure
+ }
+
+ var body: some View {
+ Group {
+ switch phase {
+ case .loading:
+ ProgressView().controlSize(.mini)
+ case .success(let image):
+ Image(uiImage: image).resizable().scaledToFit()
+ case .failure:
+ Image(systemName: "sparkles")
+ .foregroundStyle(Color(uiColor: fallbackColor))
+ }
+ }
+ .task(id: requestIdentity) {
+ do {
+ let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders(
+ for: url
+ )
+ var request = URLRequest(url: url)
+ for (name, value) in requestHeaders {
+ request.setValue(value, forHTTPHeaderField: name)
+ }
+ phase = .success(
+ try await NativeEmojiRemoteImageLoader.shared.image(for: request)
+ )
+ } catch {
+ if !Task.isCancelled { phase = .failure }
+ }
+ }
+ }
+
+ private var requestIdentity: String {
+ url.absoluteString
+ }
+}
+
+enum NativeEmojiRemoteImageError: Error {
+ case invalidResponse
+ case responseTooLarge
+ case invalidImage
+}
+
+actor NativeEmojiRemoteImageLoader {
+ typealias Downloader = (URLRequest) async throws -> UIImage
+
+ static let shared = NativeEmojiRemoteImageLoader()
+ static let defaultMaximumConcurrentDownloads = 4
+
+ private static let maximumDownloadBytes = 10 * 1024 * 1024
+ private static let maximumThumbnailPixels = 84
+ private static let defaultCacheByteLimit = 8 * 1024 * 1024
+
+ private struct Waiter {
+ let id: UUID
+ let continuation: CheckedContinuation
+ }
+
+ private let maximumConcurrentDownloads: Int
+ private let downloader: Downloader
+ private let admissionAttemptForTesting: (() -> Void)?
+ private let cache = NSCache()
+ private var activeDownloadCount = 0
+ private var waiters: [Waiter] = []
+
+ init(
+ maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads,
+ cacheByteLimit: Int = defaultCacheByteLimit,
+ admissionAttemptForTesting: (() -> Void)? = nil,
+ downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download
+ ) {
+ precondition(maximumConcurrentDownloads > 0)
+ precondition(cacheByteLimit >= 0)
+ self.maximumConcurrentDownloads = maximumConcurrentDownloads
+ self.admissionAttemptForTesting = admissionAttemptForTesting
+ self.downloader = downloader
+ cache.totalCostLimit = cacheByteLimit
+ }
+
+ func image(for request: URLRequest) async throws -> UIImage {
+ let cacheKey = request as NSURLRequest
+ if let cached = cache.object(forKey: cacheKey) {
+ return cached
+ }
+
+ recordAdmissionAttemptForTesting()
+ try await acquireDownloadSlot()
+ defer { releaseDownloadSlot() }
+
+ try Task.checkCancellation()
+ if let cached = cache.object(forKey: cacheKey) {
+ return cached
+ }
+
+ let image = try await downloader(request)
+ cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image))
+ return image
+ }
+
+ private func recordAdmissionAttemptForTesting() {
+ admissionAttemptForTesting?()
+ }
+
+ private func acquireDownloadSlot() async throws {
+ try Task.checkCancellation()
+ guard activeDownloadCount >= maximumConcurrentDownloads else {
+ activeDownloadCount += 1
+ return
+ }
+
+ let waiterID = UUID()
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation) in
+ if Task.isCancelled {
+ continuation.resume(throwing: CancellationError())
+ } else {
+ waiters.append(Waiter(id: waiterID, continuation: continuation))
+ }
+ }
+ } onCancel: {
+ Task { await self.cancelWaiter(id: waiterID) }
+ }
+ }
+
+ private func cancelWaiter(id: UUID) {
+ guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }
+ let waiter = waiters.remove(at: index)
+ waiter.continuation.resume(throwing: CancellationError())
+ }
+
+ private func releaseDownloadSlot() {
+ while !waiters.isEmpty {
+ let waiter = waiters.removeFirst()
+ waiter.continuation.resume()
+ return
+ }
+ activeDownloadCount -= 1
+ }
+
+ private static func download(_ request: URLRequest) async throws -> UIImage {
+ let (bytes, response) = try await URLSession.shared.bytes(for: request)
+ guard
+ let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode)
+ else {
+ throw NativeEmojiRemoteImageError.invalidResponse
+ }
+ if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
+ let byteCount = Int(contentLength),
+ byteCount > maximumDownloadBytes
+ {
+ throw NativeEmojiRemoteImageError.responseTooLarge
+ }
+
+ var data = Data()
+ let expected = httpResponse.expectedContentLength
+ if expected > 0 {
+ data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes))))
+ }
+ for try await byte in bytes {
+ guard data.count < maximumDownloadBytes else {
+ throw NativeEmojiRemoteImageError.responseTooLarge
+ }
+ data.append(byte)
+ }
+ try Task.checkCancellation()
+ guard let image = thumbnail(from: data) else {
+ throw NativeEmojiRemoteImageError.invalidImage
+ }
+ return image
+ }
+
+ private static func thumbnail(from data: Data) -> UIImage? {
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
+ return nil
+ }
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels,
+ kCGImageSourceShouldCacheImmediately: true,
+ ]
+ guard
+ let image = CGImageSourceCreateThumbnailAtIndex(
+ source,
+ 0,
+ options as CFDictionary
+ )
+ else {
+ return nil
+ }
+ return UIImage(cgImage: image)
+ }
+
+ private static func cacheCost(for image: UIImage) -> Int {
+ guard let cgImage = image.cgImage else { return 0 }
+ let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow(
+ by: cgImage.height
+ )
+ return overflow ? Int.max : cost
+ }
+}
diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift
new file mode 100644
index 0000000000..4b0d637ced
--- /dev/null
+++ b/mobile/ios/Runner/NativeMessageActionSurface.swift
@@ -0,0 +1,453 @@
+import Flutter
+import UIKit
+
+struct NativeMessageActionDefinition {
+ enum Group: String, CaseIterable {
+ case primary
+ case utility
+ case destructive
+ }
+
+ let id: String
+ let title: String
+ let symbol: String
+ let group: Group
+ let isDestructive: Bool
+
+ init?(arguments: [String: Any]) {
+ guard
+ let id = arguments["id"] as? String,
+ let title = arguments["title"] as? String,
+ let symbol = arguments["symbol"] as? String,
+ let groupName = arguments["group"] as? String,
+ let group = Group(rawValue: groupName)
+ else {
+ return nil
+ }
+
+ self.id = id
+ self.title = title
+ self.symbol = symbol
+ self.group = group
+ isDestructive = arguments["destructive"] as? Bool ?? false
+ }
+}
+
+enum NativeMessageActionSurfaceLayout {
+ static let minimumRowHeight: CGFloat = 48
+ static let rowVerticalPadding: CGFloat = 4
+ static let separatorHeight: CGFloat = 0.5
+ static let verticalInset: CGFloat = 4
+ static let horizontalInset: CGFloat = 16
+ static let iconColumnWidth: CGFloat = 32
+ static let iconToTextSpacing: CGFloat = 12
+
+ static var cornerRadius: CGFloat {
+ if #available(iOS 26.0, *) {
+ return 33
+ }
+ return 12
+ }
+
+ static func populatedGroups(
+ actions: [NativeMessageActionDefinition]
+ ) -> [NativeMessageActionDefinition.Group] {
+ NativeMessageActionDefinition.Group.allCases.filter { group in
+ actions.contains { $0.group == group }
+ }
+ }
+
+ static func separatorCount(
+ actions: [NativeMessageActionDefinition]
+ ) -> Int {
+ max(0, populatedGroups(actions: actions).count - 1)
+ }
+
+ static func rowHeight(
+ minimumHeight: CGFloat = minimumRowHeight,
+ compatibleWith traitCollection: UITraitCollection? = nil
+ ) -> CGFloat {
+ let labelHeight = UIFont.preferredFont(
+ forTextStyle: .body,
+ compatibleWith: traitCollection
+ ).lineHeight
+ return max(
+ minimumHeight,
+ ceil(labelHeight + (rowVerticalPadding * 2))
+ )
+ }
+
+ static func preferredHeight(
+ actions: [NativeMessageActionDefinition],
+ minimumRowHeight: CGFloat = minimumRowHeight,
+ compatibleWith traitCollection: UITraitCollection? = nil
+ ) -> CGFloat {
+ let resolvedRowHeight = rowHeight(
+ minimumHeight: minimumRowHeight,
+ compatibleWith: traitCollection
+ )
+ return (verticalInset * 2)
+ + (CGFloat(actions.count) * resolvedRowHeight)
+ + (CGFloat(separatorCount(actions: actions)) * separatorHeight)
+ }
+}
+
+@available(iOS 16.0, *)
+enum NativeMessageActionSurfaceAppearance {
+ // The native list is only one sibling inside the Flutter-owned dialog.
+ // Keeping it non-modal leaves the reaction tray and dismiss barrier
+ // reachable to VoiceOver.
+ static let actionListAccessibilityViewIsModal = false
+
+ static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle {
+ switch value as? String {
+ case "dark":
+ return .dark
+ case "light":
+ return .light
+ default:
+ return .unspecified
+ }
+ }
+
+ static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
+ guard !reduceTransparency else { return nil }
+
+ if #available(iOS 26.0, *) {
+ let effect = UIGlassEffect(style: .regular)
+ effect.isInteractive = true
+ return effect
+ }
+ return UIBlurEffect(style: .systemMaterial)
+ }
+}
+
+@available(iOS 16.0, *)
+final class NativeMessageActionRowControl: UIControl {
+ let actionImageView: UIImageView
+ let actionTitleLabel = UILabel()
+
+ init(
+ definition: NativeMessageActionDefinition,
+ foregroundColor: UIColor,
+ destructiveColor: UIColor,
+ minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
+ compatibleWith traitCollection: UITraitCollection? = nil,
+ onSelected: @escaping () -> Void
+ ) {
+ actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
+ super.init(frame: .zero)
+
+ let color = definition.isDestructive ? destructiveColor : foregroundColor
+ actionImageView.tintColor = color
+ actionImageView.contentMode = .center
+
+ actionTitleLabel.text = definition.title
+ actionTitleLabel.textColor = color
+ actionTitleLabel.font = UIFont.preferredFont(
+ forTextStyle: .body,
+ compatibleWith: traitCollection
+ )
+ actionTitleLabel.adjustsFontForContentSizeCategory = true
+ actionTitleLabel.numberOfLines = 0
+ actionTitleLabel.lineBreakMode = .byWordWrapping
+ actionTitleLabel.setContentCompressionResistancePriority(
+ .required,
+ for: .vertical
+ )
+
+ let iconColumn = UIView()
+ iconColumn.translatesAutoresizingMaskIntoConstraints = false
+ actionImageView.translatesAutoresizingMaskIntoConstraints = false
+ actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
+ iconColumn.addSubview(actionImageView)
+ addSubview(iconColumn)
+ addSubview(actionTitleLabel)
+
+ NSLayoutConstraint.activate([
+ iconColumn.leadingAnchor.constraint(
+ equalTo: leadingAnchor,
+ constant: NativeMessageActionSurfaceLayout.horizontalInset
+ ),
+ iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
+ iconColumn.widthAnchor.constraint(
+ equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
+ ),
+ iconColumn.heightAnchor.constraint(
+ equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
+ ),
+ actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
+ actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
+ actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
+ actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
+ actionTitleLabel.leadingAnchor.constraint(
+ equalTo: iconColumn.trailingAnchor,
+ constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
+ ),
+ actionTitleLabel.trailingAnchor.constraint(
+ equalTo: trailingAnchor,
+ constant: -NativeMessageActionSurfaceLayout.horizontalInset
+ ),
+ actionTitleLabel.topAnchor.constraint(
+ greaterThanOrEqualTo: topAnchor,
+ constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
+ ),
+ actionTitleLabel.bottomAnchor.constraint(
+ lessThanOrEqualTo: bottomAnchor,
+ constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
+ ),
+ actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+ heightAnchor.constraint(
+ greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
+ minimumHeight: minimumHeight,
+ compatibleWith: traitCollection
+ )
+ ),
+ ])
+
+ accessibilityLabel = definition.title
+ accessibilityTraits = .button
+ isAccessibilityElement = true
+ addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is unavailable")
+ }
+
+ override var isHighlighted: Bool {
+ didSet {
+ backgroundColor =
+ isHighlighted
+ ? actionTitleLabel.textColor.withAlphaComponent(0.08)
+ : .clear
+ }
+ }
+}
+
+@available(iOS 16.0, *)
+final class NativeMessageActionSeparatorView: UIView {
+ init(color: UIColor) {
+ super.init(frame: .zero)
+ backgroundColor = color
+ heightAnchor.constraint(
+ equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
+ ).isActive = true
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is unavailable")
+ }
+}
+
+@available(iOS 16.0, *)
+final class NativeMessageActionSurfaceFactory: NSObject,
+ FlutterPlatformViewFactory
+{
+ private let messenger: FlutterBinaryMessenger
+
+ init(messenger: FlutterBinaryMessenger) {
+ self.messenger = messenger
+ super.init()
+ }
+
+ func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
+ FlutterStandardMessageCodec.sharedInstance()
+ }
+
+ func create(
+ withFrame frame: CGRect,
+ viewIdentifier viewId: Int64,
+ arguments args: Any?
+ ) -> FlutterPlatformView {
+ NativeMessageActionSurfacePlatformView(
+ frame: frame,
+ viewIdentifier: viewId,
+ messenger: messenger,
+ arguments: args
+ )
+ }
+}
+
+@available(iOS 16.0, *)
+final class NativeMessageActionSurfacePlatformView: NSObject,
+ FlutterPlatformView
+{
+ private let surfaceView: UIView
+ private let backdropView: UIVisualEffectView
+ private let channel: FlutterMethodChannel
+
+ init(
+ frame: CGRect,
+ viewIdentifier viewId: Int64,
+ messenger: FlutterBinaryMessenger,
+ arguments args: Any?
+ ) {
+ let arguments = args as? [String: Any]
+ let surfaceColor = Self.color(
+ from: arguments?["surfaceColor"],
+ fallback: .systemBackground
+ )
+ let foregroundColor = Self.color(
+ from: arguments?["foregroundColor"],
+ fallback: .label
+ )
+ let separatorColor = Self.color(
+ from: arguments?["separatorColor"],
+ fallback: .separator
+ )
+ let destructiveColor = Self.color(
+ from: arguments?["errorColor"],
+ fallback: .systemRed
+ )
+ let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
+ from: arguments?["interfaceStyle"]
+ )
+ var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
+ if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
+ requestedRowHeight.doubleValue.isFinite
+ {
+ minimumRowHeight = max(
+ minimumRowHeight,
+ CGFloat(requestedRowHeight.doubleValue)
+ )
+ }
+ let actionArguments = arguments?["actions"] as? [[String: Any]]
+ let actions =
+ actionArguments?.compactMap(
+ NativeMessageActionDefinition.init(arguments:)
+ ) ?? []
+
+ surfaceView = UIView(frame: frame)
+ backdropView = UIVisualEffectView(
+ effect: NativeMessageActionSurfaceAppearance.backdropEffect(
+ reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
+ )
+ )
+ channel = FlutterMethodChannel(
+ name: "buzz/native_message_action_surface/\(viewId)",
+ binaryMessenger: messenger
+ )
+ super.init()
+
+ surfaceView.backgroundColor = .clear
+ surfaceView.clipsToBounds = false
+ surfaceView.accessibilityViewIsModal =
+ NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
+ surfaceView.overrideUserInterfaceStyle = interfaceStyle
+
+ backdropView.translatesAutoresizingMaskIntoConstraints = false
+ backdropView.overrideUserInterfaceStyle = interfaceStyle
+ backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
+ backdropView.layer.cornerCurve = .continuous
+ backdropView.layer.masksToBounds = true
+ if backdropView.effect == nil {
+ backdropView.backgroundColor = surfaceColor
+ }
+ surfaceView.addSubview(backdropView)
+ NSLayoutConstraint.activate([
+ backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
+ backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
+ backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
+ backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
+ ])
+
+ if #unavailable(iOS 26.0) {
+ surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
+ surfaceView.layer.shadowRadius = 32
+ surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
+ surfaceView.layer.shadowColor = UIColor.black.cgColor
+ surfaceView.layer.shadowOpacity = 0.2
+ }
+
+ install(
+ actions: actions,
+ foregroundColor: foregroundColor,
+ destructiveColor: destructiveColor,
+ separatorColor: separatorColor,
+ minimumRowHeight: minimumRowHeight
+ )
+ }
+
+ func view() -> UIView {
+ surfaceView
+ }
+
+ private func install(
+ actions: [NativeMessageActionDefinition],
+ foregroundColor: UIColor,
+ destructiveColor: UIColor,
+ separatorColor: UIColor,
+ minimumRowHeight: CGFloat
+ ) {
+ let scrollView = UIScrollView()
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.alwaysBounceVertical = false
+ scrollView.showsVerticalScrollIndicator = false
+ scrollView.contentInsetAdjustmentBehavior = .never
+
+ let stack = UIStackView()
+ stack.axis = .vertical
+ stack.spacing = 0
+ stack.translatesAutoresizingMaskIntoConstraints = false
+
+ backdropView.contentView.addSubview(scrollView)
+ scrollView.addSubview(stack)
+ NSLayoutConstraint.activate([
+ scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
+ scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
+ stack.topAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.topAnchor,
+ constant: NativeMessageActionSurfaceLayout.verticalInset
+ ),
+ stack.bottomAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.bottomAnchor,
+ constant: -NativeMessageActionSurfaceLayout.verticalInset
+ ),
+ stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
+ ])
+
+ var installedGroup = false
+ for group in NativeMessageActionDefinition.Group.allCases {
+ let groupActions = actions.filter { $0.group == group }
+ guard !groupActions.isEmpty else { continue }
+ if installedGroup {
+ stack.addArrangedSubview(
+ NativeMessageActionSeparatorView(color: separatorColor)
+ )
+ }
+ for definition in groupActions {
+ stack.addArrangedSubview(
+ NativeMessageActionRowControl(
+ definition: definition,
+ foregroundColor: foregroundColor,
+ destructiveColor: destructiveColor,
+ minimumHeight: minimumRowHeight,
+ onSelected: { [weak self] in self?.select(definition) }
+ )
+ )
+ }
+ installedGroup = true
+ }
+ }
+
+ private func select(_ definition: NativeMessageActionDefinition) {
+ channel.invokeMethod("selected", arguments: ["id": definition.id])
+ }
+
+ private static func color(from value: Any?, fallback: UIColor) -> UIColor {
+ guard let number = value as? NSNumber else { return fallback }
+ let color = number.uint32Value
+ let alpha = CGFloat((color >> 24) & 0xFF) / 255
+ let red = CGFloat((color >> 16) & 0xFF) / 255
+ let green = CGFloat((color >> 8) & 0xFF) / 255
+ let blue = CGFloat(color & 0xFF) / 255
+ return UIColor(red: red, green: green, blue: blue, alpha: alpha)
+ }
+}
diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift
index e1c2ce00f6..e4d56c4ed5 100644
--- a/mobile/ios/RunnerTests/RunnerTests.swift
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -403,6 +403,324 @@ class RunnerTests: XCTestCase {
}
}
+ func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() {
+ let order = ["people", "nature", "flags"]
+ let offsets: [String: CGFloat] = [
+ "people": -320,
+ "nature": -12,
+ "flags": 200,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0
+ ),
+ "nature"
+ )
+ }
+
+ func testCategoryTrackerFollowsScrollPastEachHeader() {
+ let order = ["people", "nature", "flags"]
+
+ // Scrolled to the very top: the first section is highlighted.
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: ["people": 0, "nature": 400, "flags": 800],
+ viewportTop: 0
+ ),
+ "people"
+ )
+
+ // Scrolled far enough that Flags has reached the top.
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: ["people": -800, "nature": -400, "flags": 0],
+ viewportTop: 0
+ ),
+ "flags"
+ )
+ }
+
+ func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() {
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: ["people", "nature"],
+ offsets: ["people": 40, "nature": 400],
+ viewportTop: 0
+ ),
+ "people"
+ )
+ }
+
+ func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() {
+ // The list has overflowed (People scrolled above the top) and its end is on
+ // screen, but the short Custom section's header sits below the top because
+ // the content clamps before it can reach it. The rail must still highlight
+ // Custom rather than leaving Nature — its predecessor — selected.
+ let order = ["people", "nature", "custom"]
+ let offsets: [String: CGFloat] = [
+ "people": -900,
+ "nature": -420,
+ "custom": 360,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 500
+ ),
+ "custom"
+ )
+ }
+
+ func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() {
+ // The same short-final geometry, but the content end is still below the
+ // viewport (the user has not reached the bottom), so the ordinary
+ // header-at-top rule applies and Nature stays selected.
+ let order = ["people", "nature", "custom"]
+ let offsets: [String: CGFloat] = [
+ "people": -900,
+ "nature": -420,
+ "custom": 360,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 900
+ ),
+ "nature"
+ )
+ }
+
+ func testCategoryTrackerDoesNotForceLastSectionForAShortList() {
+ // A list that fits without scrolling has its content end on screen too, but
+ // its first header is still at the top — so the bottom rule must not fire
+ // and steal the highlight to the final section.
+ let order = ["people", "nature"]
+ let offsets: [String: CGFloat] = ["people": 0, "nature": 120]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 240
+ ),
+ "people"
+ )
+ }
+
+ func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
+ let maximumConcurrentDownloads = 3
+ let taskCount = 8
+ let probe = NativeEmojiDownloadProbe()
+ let tasksAttemptedAdmission = XCTestExpectation(
+ description: "all download tasks attempted admission"
+ )
+ tasksAttemptedAdmission.expectedFulfillmentCount = taskCount
+ let loader = NativeEmojiRemoteImageLoader(
+ maximumConcurrentDownloads: maximumConcurrentDownloads,
+ cacheByteLimit: 0,
+ admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() },
+ downloader: { _ in
+ await probe.holdDownload()
+ return UIImage()
+ }
+ )
+ let tasks = (0.. UIImage {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
@@ -630,3 +948,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
}
+
+private actor NativeEmojiDownloadProbe {
+ private struct MilestoneWaiter {
+ let count: Int
+ let continuation: CheckedContinuation
+ }
+
+ private var active = 0
+ private var peakActive = 0
+ private var started = 0
+ private var releaseContinuations: [CheckedContinuation] = []
+ private var milestoneWaiters: [MilestoneWaiter] = []
+
+ func holdDownload() async {
+ active += 1
+ started += 1
+ peakActive = max(peakActive, active)
+ resumeReachedMilestones()
+ await withCheckedContinuation { continuation in
+ releaseContinuations.append(continuation)
+ }
+ active -= 1
+ }
+
+ func waitUntilStarted(_ count: Int) async {
+ guard started < count else { return }
+ await withCheckedContinuation { continuation in
+ milestoneWaiters.append(
+ MilestoneWaiter(count: count, continuation: continuation)
+ )
+ }
+ }
+
+ func releaseOne() {
+ guard !releaseContinuations.isEmpty else { return }
+ releaseContinuations.removeFirst().resume()
+ }
+
+ func releaseAll() {
+ let continuations = releaseContinuations
+ releaseContinuations.removeAll()
+ for continuation in continuations {
+ continuation.resume()
+ }
+ }
+
+ func snapshot() -> (active: Int, peakActive: Int, started: Int) {
+ (active, peakActive, started)
+ }
+
+ private func resumeReachedMilestones() {
+ let reached = milestoneWaiters.filter { $0.count <= started }
+ milestoneWaiters.removeAll { $0.count <= started }
+ for waiter in reached {
+ waiter.continuation.resume()
+ }
+ }
+}
diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart
index dcae998ed0..62dfb18fc7 100644
--- a/mobile/lib/features/activity/activity_page.dart
+++ b/mobile/lib/features/activity/activity_page.dart
@@ -220,6 +220,8 @@ class ActivityPage extends HookConsumerWidget {
channel: channel,
initialMessageId: target.id,
initialThreadRootId: threadRootId,
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
@@ -240,6 +242,8 @@ class ActivityPage extends HookConsumerWidget {
builder: (_) => ChannelDetailPage(
channel: channel,
initialThreadRootId: draft.threadHeadId,
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 6e7449a633..a36b951e42 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -123,21 +123,36 @@ int? _channelReadTimestamp({
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
+/// Controls how a hydrated initial thread is added to the navigation stack.
+enum InitialThreadRouteBehavior {
+ /// Keep the channel route beneath the thread.
+ push,
+
+ /// Replace the temporary channel route so Back returns to its origin.
+ replaceCurrentRoute,
+}
+
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
final String? initialMessageId;
final String? initialThreadRootId;
+ /// How the automatically opened initial thread affects the route stack.
+ final InitialThreadRouteBehavior initialThreadRouteBehavior;
+
const ChannelDetailPage({
super.key,
required this.channel,
this.initialMessageId,
this.initialThreadRootId,
+ this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final composerDockHeight = useState(0.0);
+ final composerFocusNode = useFocusNode();
+ final restoreComposerFocus = useRef(null);
final sendMessage = ref.read(sendMessageProvider);
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
@@ -451,6 +466,8 @@ class ChannelDetailPage extends HookConsumerWidget {
allMessages: messages,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
+ initialThreadRouteBehavior:
+ initialThreadRouteBehavior,
initialOrdinaryUnreadMessageIds:
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId:
@@ -473,6 +490,12 @@ class ChannelDetailPage extends HookConsumerWidget {
composerBottomInset: showsComposer
? composerDockHeight.value
: 0,
+ composerFocusNode: showsComposer
+ ? composerFocusNode
+ : null,
+ restoreComposerFocus: showsComposer
+ ? () => restoreComposerFocus.value?.call()
+ : null,
);
},
),
@@ -521,6 +544,9 @@ class ChannelDetailPage extends HookConsumerWidget {
),
ComposeBar(
channelId: channel.id,
+ focusNode: composerFocusNode,
+ onFocusRestorerChanged: (restoreFocus) =>
+ restoreComposerFocus.value = restoreFocus,
channelName: resolvedChannel.isDm
? ''
: resolvedChannel.name,
diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
index 87673b3cbe..8c953b7211 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
@@ -1,6 +1,6 @@
part of '../channel_detail_page.dart';
-class _MessageBubble extends ConsumerWidget {
+class _MessageBubble extends HookConsumerWidget {
final TimelineMessage message;
final bool showAuthor;
final Map channelNames;
@@ -9,6 +9,8 @@ class _MessageBubble extends ConsumerWidget {
final List? allMessages;
final bool isMember;
final bool isArchived;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
const _MessageBubble({
required this.message,
@@ -19,10 +21,13 @@ class _MessageBubble extends ConsumerWidget {
this.allMessages,
this.isMember = false,
this.isArchived = false,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final profile =
@@ -72,7 +77,7 @@ class _MessageBubble extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);
- void openMessageActions(Rect anchorRect) {
+ void openMessageActions(MessageLongPressDetails details) {
showMessageActions(
context: context,
ref: ref,
@@ -83,7 +88,12 @@ class _MessageBubble extends ConsumerWidget {
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
- anchorRect: anchorRect,
+ anchorRect: details.anchorRect,
+ captureAnchorSnapshot: details.captureSnapshot,
+ onPopoverPreviewVisibilityChanged: details.setSourceHidden,
+ onPopoverDismissed: () => details.setSourceHidden(false),
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
);
}
@@ -98,9 +108,10 @@ class _MessageBubble extends ConsumerWidget {
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
- onLongPress: openMessageActions,
+ onLongPressDetails: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
+ snapshotKey: messageSnapshotKey,
// Tap opens the thread; long-press still opens the action sheet.
// MessageContent handles mention, channel-link, and media taps.
onTap: allMessages == null
@@ -122,143 +133,164 @@ class _MessageBubble extends ConsumerWidget {
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- if (showAuthor)
- GestureDetector(
- onTap: () => showUserProfileSheet(context, message.pubkey),
- child: _UserAvatar(
- profile: profile,
- pubkey: message.pubkey,
- ),
- )
- else
- const SizedBox(width: messageAvatarSize),
- const SizedBox(width: messageAvatarContentGap),
- Expanded(
- child: Padding(
- padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (showAuthor)
- Padding(
- padding: const EdgeInsets.only(
- bottom: Grid.quarter,
- ),
- child: Row(
- children: [
- Expanded(
- child: MessageAuthorMeta(
- displayName: displayName,
- username: messageUsernameLabel(profile),
- timestamp: formatMessageTime(
- message.createdAt,
- ),
- nameColor: context.colors.onSurface,
- metadataColor:
- context.colors.onSurfaceVariant,
- onAuthorTap: () => showUserProfileSheet(
- context,
- message.pubkey,
- ),
- displayNameKey: ValueKey(
- 'message-author-${message.id}',
- ),
- usernameKey: ValueKey(
- 'message-username-${message.id}',
- ),
- timestampKey: ValueKey(
- 'message-timestamp-${message.id}',
- ),
+ RepaintBoundary(
+ key: messageSnapshotKey,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ GestureDetector(
+ onTap: () =>
+ showUserProfileSheet(context, message.pubkey),
+ child: _UserAvatar(
+ profile: profile,
+ pubkey: message.pubkey,
+ ),
+ )
+ else
+ const SizedBox(width: messageAvatarSize),
+ const SizedBox(width: messageAvatarContentGap),
+ Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ top: showAuthor ? Grid.half : 0,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ Padding(
+ padding: const EdgeInsets.only(
+ bottom: Grid.quarter,
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: MessageAuthorMeta(
+ displayName: displayName,
+ username: messageUsernameLabel(
+ profile,
+ ),
+ timestamp: formatMessageTime(
+ message.createdAt,
+ ),
+ nameColor: context.colors.onSurface,
+ metadataColor:
+ context.colors.onSurfaceVariant,
+ onAuthorTap: () =>
+ showUserProfileSheet(
+ context,
+ message.pubkey,
+ ),
+ displayNameKey: ValueKey(
+ 'message-author-${message.id}',
+ ),
+ usernameKey: ValueKey(
+ 'message-username-${message.id}',
+ ),
+ timestampKey: ValueKey(
+ 'message-timestamp-${message.id}',
+ ),
+ ),
+ ),
+ if (message.edited) ...[
+ const SizedBox(width: Grid.half),
+ Text(
+ '(edited)',
+ style: context.textTheme.labelSmall
+ ?.copyWith(
+ color: context
+ .colors
+ .onSurfaceVariant,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ],
),
),
- if (message.edited) ...[
- const SizedBox(width: Grid.half),
- Text(
- '(edited)',
- style: context.textTheme.labelSmall
- ?.copyWith(
- color:
- context.colors.onSurfaceVariant,
- fontStyle: FontStyle.italic,
- ),
- ),
- ],
- ],
- ),
- ),
- MessageContent(
- content: message.content,
- mentionNames: resolvedMentionNames,
- agentMentionPubkeys: agentMentionPubkeys,
- channelNames: channelNames,
- tags: message.tags,
- baseStyle: messageBodyTextStyle.copyWith(
- color: context.colors.onSurface,
- ),
- scaleEmojiOnly: true,
- mediaCarouselTrailingOverflow: Grid.gutter,
- onMediaReply: allMessages == null
- ? null
- : () {
- if (!context.mounted) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ThreadDetailPage(
- threadHead: message,
- allMessages: allMessages!,
- channelId: currentChannelId,
- currentPubkey: currentPubkey,
- isMember: isMember,
- isArchived: isArchived,
- ),
+ MessageContent(
+ content: message.content,
+ mentionNames: resolvedMentionNames,
+ agentMentionPubkeys: agentMentionPubkeys,
+ channelNames: channelNames,
+ tags: message.tags,
+ baseStyle: messageBodyTextStyle.copyWith(
+ color: context.colors.onSurface,
+ ),
+ scaleEmojiOnly: true,
+ mediaCarouselTrailingOverflow: Grid.gutter,
+ onMediaReply: allMessages == null
+ ? null
+ : () {
+ if (!context.mounted) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: message,
+ allMessages: allMessages!,
+ channelId: currentChannelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ ),
+ ),
+ );
+ },
+ onMediaMore: (viewerContext, imageUrl) =>
+ showImageActions(
+ context: viewerContext,
+ ref: ref,
+ message: message,
+ channelId: currentChannelId,
+ imageUrl: imageUrl,
+ canManageMessage: canManageMessage,
+ onDeleted: () {
+ if (viewerContext.mounted) {
+ Navigator.of(
+ viewerContext,
+ ).maybePop();
+ }
+ },
),
+ onChannelTap: (channelId) {
+ openChannelLink(
+ context: context,
+ ref: ref,
+ channelId: channelId,
+ currentChannelId: currentChannelId,
);
},
- onMediaMore: (viewerContext, imageUrl) =>
- showImageActions(
- context: viewerContext,
- ref: ref,
- message: message,
- channelId: currentChannelId,
- imageUrl: imageUrl,
- canManageMessage: canManageMessage,
- onDeleted: () {
- if (viewerContext.mounted) {
- Navigator.of(viewerContext).maybePop();
- }
- },
+ onMentionTap: (pubkey) =>
+ showUserProfileSheet(context, pubkey),
),
- onChannelTap: (channelId) {
- openChannelLink(
- context: context,
- ref: ref,
- channelId: channelId,
- currentChannelId: currentChannelId,
- );
- },
- onMentionTap: (pubkey) =>
- showUserProfileSheet(context, pubkey),
- ),
- if (message.reactions.isNotEmpty)
- ReactionRow(
- messageId: message.id,
- reactions: message.reactions,
- onToggle: (emoji) =>
- toggleReaction(ref, message, emoji),
- showAddButton: isMember && !isArchived,
- onAddReaction: () => showAddReactionPicker(
- context: context,
- ref: ref,
- message: message,
- ),
+ ],
),
- ],
- ),
+ ),
+ ),
+ ],
),
),
+ if (message.reactions.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(
+ left: messageAvatarSize + messageAvatarContentGap,
+ ),
+ child: ReactionRow(
+ messageId: message.id,
+ reactions: message.reactions,
+ onToggle: (emoji) => toggleReaction(ref, message, emoji),
+ showAddButton: isMember && !isArchived,
+ onAddReaction: () => showAddReactionPicker(
+ context: context,
+ ref: ref,
+ message: message,
+ ),
+ ),
+ ),
],
),
),
diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart
index 59e4c746fb..06308063ab 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -5,6 +5,7 @@ class _MessageList extends HookConsumerWidget {
final List allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
+ final InitialThreadRouteBehavior initialThreadRouteBehavior;
final Set initialOrdinaryUnreadMessageIds;
final String? initialOldestOrdinaryUnreadMessageId;
final Set initialForcedUnreadMessageIds;
@@ -15,12 +16,15 @@ class _MessageList extends HookConsumerWidget {
final bool isArchived;
final double appBarTitleContentHeight;
final double composerBottomInset;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
const _MessageList({
required this.entries,
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
+ required this.initialThreadRouteBehavior,
required this.initialOrdinaryUnreadMessageIds,
required this.initialOldestOrdinaryUnreadMessageId,
required this.initialForcedUnreadMessageIds,
@@ -31,6 +35,8 @@ class _MessageList extends HookConsumerWidget {
required this.isArchived,
required this.appBarTitleContentHeight,
required this.composerBottomInset,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
@@ -565,23 +571,30 @@ class _MessageList extends HookConsumerWidget {
if (threadHead == null) return null;
didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!context.mounted) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ThreadDetailPage(
- threadHead: threadHead,
- allMessages: allMessages,
- channelId: channelId,
- currentPubkey: currentPubkey,
- isMember: isMember,
- isArchived: isArchived,
- initialMessageId: initialMessageId,
- ),
+ if (!context.mounted || ModalRoute.of(context)?.isCurrent != true) {
+ return;
+ }
+ final route = MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: allMessages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ initialMessageId: initialMessageId,
),
);
+ final navigator = Navigator.of(context);
+ switch (initialThreadRouteBehavior) {
+ case InitialThreadRouteBehavior.push:
+ navigator.push(route);
+ case InitialThreadRouteBehavior.replaceCurrentRoute:
+ navigator.pushReplacement(route);
+ }
});
return null;
- }, [initialThreadRootId, allMessages]);
+ }, [initialThreadRootId, allMessages, initialThreadRouteBehavior]);
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
@@ -804,6 +817,8 @@ class _MessageList extends HookConsumerWidget {
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
),
if (entry.summary != null)
_ThreadSummaryRow(
diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
index c6a26cc59c..344972f10e 100644
--- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
+++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
@@ -10,6 +10,12 @@ class ComposeBar extends HookConsumerWidget {
/// prepare focus-dependent layout (for example, following a thread tail).
final VoidCallback? onFocusRequested;
+ /// Parent-owned if set; otherwise internally created and disposed.
+ final FocusNode? focusNode;
+
+ /// Receives a restorer which becomes a no-op after replacement/unmount.
+ final ValueChanged? onFocusRestorerChanged;
+
/// Optional thread IDs for thread-scoped typing indicators.
final String? threadHeadId;
final String? rootId;
@@ -20,6 +26,8 @@ class ComposeBar extends HookConsumerWidget {
this.hintText,
this.threadHeadId,
this.rootId,
+ this.focusNode,
+ this.onFocusRestorerChanged,
this.onFocusRequested,
required this.onSend,
});
@@ -31,15 +39,9 @@ class ComposeBar extends HookConsumerWidget {
() => controller.text,
);
useEffect(() => controller.dispose, [controller]);
- // Restore and persist unsent text as a local draft so the Activity
- // inbox Drafts filter reflects real composer state.
- //
- // The effect is additionally keyed on the active relay + pubkey identity:
- // provider-level namespacing alone cannot protect a composer that stays
- // mounted through an in-place community/account switch — the controller
- // would retain the old identity's text and the next edit would persist it
- // into the new identity's store. On identity change we replace the
- // controller content with the new identity's own saved draft (or clear).
+ // Draft identity is part of the effect key because an in-place account or
+ // community switch can leave this composer mounted. Reload that identity's
+ // draft so old text cannot be persisted into the new identity's store.
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity =
@@ -50,15 +52,13 @@ class ComposeBar extends HookConsumerWidget {
defaultTargetPlatform != TargetPlatform.android,
);
final androidImeFallbackTimer = useRef(null);
- final focusNode = useFocusNode();
+ final ownedFocusNode = useFocusNode();
+ final focusNode = this.focusNode ?? ownedFocusNode;
useEffect(
- () =>
- () => androidImeFallbackTimer.value?.cancel(),
- [androidImeFallbackTimer],
- );
- useEffect(
- () =>
- () => _dismissComposerKeyboard(focusNode),
+ () => () {
+ androidImeFallbackTimer.value?.cancel();
+ _dismissComposerKeyboard(focusNode);
+ },
[focusNode],
);
final isEmojiPickerOpen = useState(false);
@@ -863,6 +863,13 @@ class ComposeBar extends HookConsumerWidget {
androidImeFallbackTimer: androidImeFallbackTimer,
);
+ _useComposerFocusRestorer(
+ onChanged: onFocusRestorerChanged,
+ isExpanded: isComposerExpanded,
+ focusNode: focusNode,
+ expand: expandComposer,
+ );
+
final suggestionPanel = _composerSuggestionPanel(
channelSuggestions: channelSuggestions,
mentionSuggestions: suggestions,
diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart
index 09d7a8e847..78d31e5819 100644
--- a/mobile/lib/features/channels/compose_bar/helpers.dart
+++ b/mobile/lib/features/channels/compose_bar/helpers.dart
@@ -1,5 +1,29 @@
part of '../compose_bar.dart';
+void _useComposerFocusRestorer({
+ required ValueChanged? onChanged,
+ required ValueNotifier isExpanded,
+ required FocusNode focusNode,
+ required VoidCallback expand,
+}) {
+ useEffect(() {
+ if (onChanged == null) return null;
+
+ var isCurrent = true;
+ void restoreFocus() {
+ if (!isCurrent) return;
+ if (isExpanded.value) {
+ focusNode.requestFocus();
+ } else {
+ expand();
+ }
+ }
+
+ onChanged(restoreFocus);
+ return () => isCurrent = false;
+ }, [onChanged, focusNode]);
+}
+
void _useComposerChannelNames(
_MarkdownEditingController controller,
AsyncValue> channelsAsync,
diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart
index e8e62199d7..b66d526e7e 100644
--- a/mobile/lib/features/channels/emoji_picker.dart
+++ b/mobile/lib/features/channels/emoji_picker.dart
@@ -1,4 +1,8 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -10,17 +14,19 @@ import '../../shared/emoji/emoji_data.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_search.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
+import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
+import '../../shared/widgets/buzz_sheet_header.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'recent_emoji_provider.dart';
part 'emoji_picker/search_field.dart';
part 'emoji_picker/category_rail.dart';
part 'emoji_picker/emoji_grid.dart';
+part 'emoji_picker/ios_native_picker.dart';
-/// Height of the picker sheet as a fraction of the screen. The full emoji set
-/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked
-/// subset and had no room to browse.
+/// Android keeps the established Flutter tray height. iOS is presented by a
+/// native sheet with system detents in [ios_native_picker.dart].
const _sheetHeightFactor = 0.62;
/// Opens the full emoji picker as a modal bottom sheet.
@@ -33,12 +39,34 @@ void showEmojiPicker({
required BuildContext context,
required void Function(String emoji) onSelect,
VoidCallback? onDismiss,
+}) {
+ if (defaultTargetPlatform == TargetPlatform.iOS) {
+ unawaited(
+ _presentIosEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ ),
+ );
+ return;
+ }
+
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+}
+
+void _showFlutterEmojiPicker({
+ required BuildContext context,
+ required void Function(String emoji) onSelect,
+ VoidCallback? onDismiss,
}) {
showBuzzModalBottomSheet(
context: context,
isScrollControlled: true,
- showDragHandle: true,
- backgroundColor: context.colors.surfaceContainerHighest,
+ showCloseButton: false,
builder: (sheetContext) => EmojiPickerSheet(
onSelect: (emoji) {
Navigator.of(sheetContext).pop();
@@ -53,11 +81,41 @@ class EmojiPickerSheet extends HookConsumerWidget {
const EmojiPickerSheet({super.key, required this.onSelect});
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return SizedBox(
+ height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
+ child: _EmojiPickerContent(onSelect: onSelect),
+ );
+ }
+}
+
+class _EmojiPickerContent extends HookConsumerWidget {
+ const _EmojiPickerContent({required this.onSelect});
+
+ final void Function(String emoji) onSelect;
+
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
final customEmoji = ref.watch(customEmojiListProvider);
final recent = ref.watch(recentEmojiProvider);
+ final prefs = ref.read(savedPrefsProvider);
+ final skinTone = useState(
+ _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
+ );
+
+ void selectSkinTone(int value) {
+ final next = _validSkinTone(value);
+ if (skinTone.value == next) return;
+ skinTone.value = next;
+ unawaited(prefs.setInt(_emojiSkinTonePrefsKey, next));
+ }
+
+ final visibleDataset = useMemoized(
+ () => _datasetForSkinTone(dataset, skinTone.value),
+ [dataset, skinTone.value],
+ );
final searchController = useTextEditingController();
final query = useState('');
@@ -74,20 +132,37 @@ class EmojiPickerSheet extends HookConsumerWidget {
final sections = useMemoized(
() => _buildSections(
- dataset: dataset,
+ dataset: visibleDataset,
+ sourceDataset: dataset,
customEmoji: customEmoji,
recent: recent,
onSelect: select,
),
- [dataset, customEmoji, recent],
+ [visibleDataset, dataset, customEmoji, recent],
);
final offsets = useMemoized(() => _sectionOffsets(sections), [sections]);
-
final scrollController = useScrollController();
+
// A notifier rather than state: the highlight changes on every scroll frame
// and only the rail needs to hear about it. Rebuilding the sheet would
// rebuild the grid underneath it.
- final activeSection = useMemoized(() => ValueNotifier(0), [sections]);
+ //
+ // Seed it from the current scroll offset rather than 0: a skin-tone change
+ // rebuilds [sections] and so replaces this notifier, but the grid keeps its
+ // scroll position (same controller, same section extents). Resetting to 0
+ // here would falsely highlight the first category until the next scroll.
+ final activeSection = useMemoized(
+ () => ValueNotifier(
+ scrollController.hasClients
+ ? _activeSectionIndex(
+ offsets,
+ scrollController.offset,
+ maxScrollExtent: scrollController.position.maxScrollExtent,
+ )
+ : 0,
+ ),
+ [sections],
+ );
useEffect(() => activeSection.dispose, [activeSection]);
useEffect(() {
@@ -96,6 +171,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
activeSection.value = _activeSectionIndex(
offsets,
scrollController.offset,
+ maxScrollExtent: scrollController.position.maxScrollExtent,
);
}
@@ -119,9 +195,9 @@ class EmojiPickerSheet extends HookConsumerWidget {
// while the sheet animates.
final results = useMemoized(
() => isSearching
- ? searchEmoji(trimmedQuery, dataset.all)
+ ? searchEmoji(trimmedQuery, visibleDataset.all)
: const [],
- [trimmedQuery, dataset],
+ [trimmedQuery, visibleDataset],
);
final customResults = useMemoized(
() => isSearching
@@ -134,37 +210,48 @@ class EmojiPickerSheet extends HookConsumerWidget {
[trimmedQuery, customEmoji],
);
- return SizedBox(
- height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
- child: Column(
- children: [
- _EmojiSearchField(controller: searchController),
- if (!isSearching && sections.isNotEmpty)
- ValueListenableBuilder(
- valueListenable: activeSection,
- builder: (context, active, _) => _CategoryRail(
- sections: sections,
- activeIndex: active,
- onSelect: jumpToSection,
+ return Column(
+ children: [
+ LayoutBuilder(
+ builder: (context, constraints) => BuzzSheetHeader(
+ showDragHandle: true,
+ leading: SizedBox(
+ width: constraints.maxWidth - Grid.gutter * 2 - 44 - Grid.xxs,
+ child: _EmojiSearchField(
+ controller: searchController,
+ padding: EdgeInsets.zero,
),
),
- Divider(height: 1, color: context.colors.outlineVariant),
- Expanded(
- child: dataset.isEmpty && customEmoji.isEmpty
- ? const Center(child: CircularProgressIndicator())
- : isSearching
- ? _EmojiSearchResults(
- entries: results,
- customEmoji: customResults,
- onSelect: select,
- )
- : _ContinuousEmojiGrid(
- sections: sections,
- controller: scrollController,
- ),
),
- ],
- ),
+ ),
+ if (!isSearching && sections.isNotEmpty)
+ ValueListenableBuilder(
+ valueListenable: activeSection,
+ builder: (context, active, _) => _CategoryRail(
+ sections: sections,
+ activeIndex: active,
+ onSelect: jumpToSection,
+ skinTone: skinTone.value,
+ onSkinToneChanged: selectSkinTone,
+ ),
+ ),
+ Divider(height: 1, color: context.colors.outlineVariant),
+ Expanded(
+ child: dataset.isEmpty && customEmoji.isEmpty
+ ? const Center(child: CircularProgressIndicator())
+ : isSearching
+ ? _EmojiSearchResults(
+ entries: results,
+ customEmoji: customResults,
+ onSelect: select,
+ controller: scrollController,
+ )
+ : _ContinuousEmojiGrid(
+ sections: sections,
+ controller: scrollController,
+ ),
+ ),
+ ],
);
}
}
@@ -177,6 +264,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
/// nowhere.
List<_EmojiSection> _buildSections({
required EmojiDataset dataset,
+ required EmojiDataset sourceDataset,
required List customEmoji,
required List recent,
required void Function(String emoji) onSelect,
@@ -186,6 +274,7 @@ List<_EmojiSection> _buildSections({
final recentTiles = _resolveRecentTiles(
recent: recent,
dataset: dataset,
+ sourceDataset: sourceDataset,
customEmoji: customEmoji,
onSelect: onSelect,
);
@@ -246,15 +335,18 @@ List<_EmojiSection> _buildSections({
List _resolveRecentTiles({
required List recent,
required EmojiDataset dataset,
+ required EmojiDataset sourceDataset,
required List customEmoji,
required void Function(String emoji) onSelect,
}) {
final customByShortcode = {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji,
};
- final entriesByNative = {
- for (final entry in dataset.all) entry.native: entry,
+ final sourceEntriesByNative = {
+ for (final entry in sourceDataset.all) entry.native: entry,
};
+ final visibleEntriesById = {for (final entry in dataset.all) entry.id: entry};
+ final seenStandardIds = {};
final tiles = [];
for (final item in recent) {
@@ -272,7 +364,9 @@ List _resolveRecentTiles({
);
continue;
}
- final entry = entriesByNative[value];
+ final sourceEntry = sourceEntriesByNative[value];
+ if (sourceEntry == null || !seenStandardIds.add(sourceEntry.id)) continue;
+ final entry = visibleEntriesById[sourceEntry.id];
if (entry == null) continue;
tiles.add(
_EmojiTile(
@@ -284,3 +378,37 @@ List _resolveRecentTiles({
}
return tiles;
}
+
+/// Project the dataset to one visible tile per shortcode. Emoji that support
+/// skin tones use the selected variant; everything else keeps its default.
+EmojiDataset _datasetForSkinTone(EmojiDataset dataset, int skinTone) {
+ if (dataset.isEmpty) return dataset;
+ final categories = [];
+ final all = [];
+
+ for (final category in dataset.categories) {
+ final variantsById = >{};
+ for (final entry in category.emoji) {
+ variantsById.putIfAbsent(entry.id, () => []).add(entry);
+ }
+ final visible = [];
+ for (final variants in variantsById.values) {
+ final selected = variants.firstWhere(
+ (entry) => entry.skinIndex == skinTone,
+ orElse: () => variants.firstWhere(
+ (entry) => entry.skinIndex == 0,
+ orElse: () => variants.first,
+ ),
+ );
+ visible.add(selected);
+ all.add(selected);
+ }
+ categories.add(EmojiCategory(id: category.id, emoji: visible));
+ }
+
+ return EmojiDataset(
+ categories: categories,
+ all: all,
+ nativeToShortcode: dataset.nativeToShortcode,
+ );
+}
diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart
index 50df140688..e654134ec6 100644
--- a/mobile/lib/features/channels/emoji_picker/category_rail.dart
+++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart
@@ -18,6 +18,20 @@ IconData _categoryIcon(String categoryId) => switch (categoryId) {
/// 18px icon it holds.
const _railHeight = 36.0;
+const _emojiSkinTonePrefsKey = 'buzz.emoji-picker.skin-tone.v1';
+
+const _skinTones = [
+ (label: 'Default', color: Color(0xFFFFC93A)),
+ (label: 'Light', color: Color(0xFFFFDAB7)),
+ (label: 'Medium-light', color: Color(0xFFE7B98F)),
+ (label: 'Medium', color: Color(0xFFC88C61)),
+ (label: 'Medium-dark', color: Color(0xFFA46134)),
+ (label: 'Dark', color: Color(0xFF5D4437)),
+];
+
+int _validSkinTone(int? value) =>
+ value != null && value >= 0 && value < _skinTones.length ? value : 0;
+
/// Category selector: one icon per section of the continuous grid, in scroll
/// order. Tapping jumps to that section; scrolling moves the highlight.
///
@@ -28,11 +42,15 @@ class _CategoryRail extends StatelessWidget {
final List<_EmojiSection> sections;
final int activeIndex;
final ValueChanged onSelect;
+ final int skinTone;
+ final ValueChanged onSkinToneChanged;
const _CategoryRail({
required this.sections,
required this.activeIndex,
required this.onSelect,
+ required this.skinTone,
+ required this.onSkinToneChanged,
});
@override
@@ -52,6 +70,12 @@ class _CategoryRail extends StatelessWidget {
onTap: () => onSelect(i),
),
),
+ Expanded(
+ child: _SkinToneSelector(
+ value: skinTone,
+ onChanged: onSkinToneChanged,
+ ),
+ ),
],
),
),
@@ -59,6 +83,99 @@ class _CategoryRail extends StatelessWidget {
}
}
+class _SkinToneSelector extends StatelessWidget {
+ const _SkinToneSelector({required this.value, required this.onChanged});
+
+ final int value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final selected = _skinTones[_validSkinTone(value)];
+ return PopupMenuButton(
+ key: const ValueKey('emoji-skin-tone-selector'),
+ initialValue: value,
+ tooltip: 'Skin tone',
+ position: PopupMenuPosition.under,
+ onSelected: onChanged,
+ itemBuilder: (context) => [
+ for (final (index, tone) in _skinTones.indexed)
+ PopupMenuItem(
+ key: ValueKey('emoji-skin-tone-$index'),
+ value: index,
+ child: Row(
+ children: [
+ _SkinToneDot(
+ key: ValueKey('emoji-skin-tone-dot-$index'),
+ color: tone.color,
+ ),
+ const SizedBox(width: Grid.xs),
+ Expanded(child: Text(tone.label)),
+ if (index == value)
+ Icon(
+ LucideIcons.check,
+ size: 18,
+ color: context.colors.primary,
+ ),
+ ],
+ ),
+ ),
+ ],
+ child: Semantics(
+ button: true,
+ label: 'Skin tone',
+ child: Center(
+ child: _SkinToneDot(
+ key: const ValueKey('emoji-skin-tone-dot-selected'),
+ color: selected.color,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SkinToneDot extends StatelessWidget {
+ const _SkinToneDot({super.key, required this.color});
+
+ final Color color;
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox.square(
+ dimension: 16,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ DecoratedBox(
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
+ ),
+ ClipOval(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ Colors.white.withValues(alpha: 0.2),
+ Colors.transparent,
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ ),
+ ),
+ DecoratedBox(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.black.withValues(alpha: 0.8)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _CategoryIcon extends StatelessWidget {
final IconData icon;
final String tooltip;
diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
index 354a31e44c..eb7217be02 100644
--- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
+++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
@@ -63,7 +63,19 @@ List _sectionOffsets(List<_EmojiSection> sections) {
}
/// Which section owns [offset] — the one whose header is pinned right now.
-int _activeSectionIndex(List offsets, double offset) {
+/// At the clamped bottom, the final visible section owns the viewport even when
+/// it is too short for its header to reach the top.
+int _activeSectionIndex(
+ List offsets,
+ double offset, {
+ required double maxScrollExtent,
+}) {
+ if (offsets.isEmpty) return 0;
+ if (maxScrollExtent > 0 &&
+ offset >= maxScrollExtent - precisionErrorTolerance) {
+ return offsets.length - 1;
+ }
+
var active = 0;
for (var i = 0; i < offsets.length; i++) {
// Half a header of slack so the highlight flips as a header reaches the
@@ -92,7 +104,7 @@ class _EmojiTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
- key: ValueKey('$keyPrefix-${entry.tileId}'),
+ key: ValueKey('$keyPrefix-${entry.id}'),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Semantics(
@@ -196,11 +208,13 @@ class _EmojiSearchResults extends StatelessWidget {
final List entries;
final List customEmoji;
final void Function(String emoji) onSelect;
+ final ScrollController controller;
const _EmojiSearchResults({
required this.entries,
required this.customEmoji,
required this.onSelect,
+ required this.controller,
});
@override
@@ -214,6 +228,7 @@ class _EmojiSearchResults extends StatelessWidget {
return CustomScrollView(
key: const ValueKey('emoji-picker-search-results'),
+ controller: controller,
slivers: [
if (customEmoji.isNotEmpty) ...[
const _SectionHeader(label: 'Custom'),
@@ -279,7 +294,7 @@ class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
bool overlapsContent,
) {
return Container(
- color: context.colors.surfaceContainerHighest,
+ color: context.colors.surface,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: _sectionLabelStyle(context)),
diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
new file mode 100644
index 0000000000..2986af9636
--- /dev/null
+++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
@@ -0,0 +1,191 @@
+part of '../emoji_picker.dart';
+
+const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
+
+/// Guards the process-global native method-call handler: one native sheet may
+/// own it at a time. A reentrant open would replace the handler and hijack the
+/// live sheet's select/dismiss callbacks, so [_presentIosEmojiPicker] coalesces
+/// reentry while a presentation is in flight.
+bool _iosEmojiPickerPresenting = false;
+
+@visibleForTesting
+void resetIosEmojiPickerPresentationForTest() {
+ _iosEmojiPickerPresenting = false;
+}
+
+Future _presentIosEmojiPicker({
+ required BuildContext context,
+ required void Function(String emoji) onSelect,
+ VoidCallback? onDismiss,
+}) async {
+ // Only one native sheet owns the handler at a time. Reject a reentrant open
+ // without replacing the live sheet's callbacks, and complete the rejected
+ // caller so its local picker-open lifecycle is not stranded.
+ if (_iosEmojiPickerPresenting) {
+ onDismiss?.call();
+ return;
+ }
+ _iosEmojiPickerPresenting = true;
+
+ final container = ProviderScope.containerOf(context, listen: false);
+ final paletteState = container.read(customEmojiPaletteProvider);
+ final List customEmoji;
+ BuildContext? loadingSheetContext;
+ var leavingLoadingSheet = false;
+ var loadingCancelled = false;
+ Future? loadingSheet;
+
+ if (paletteState case AsyncData(:final value)) {
+ customEmoji = value;
+ } else {
+ // Palette history can take the relay timeout to resolve. Give the tap an
+ // immediate, cancellable surface instead of holding the global guard while
+ // the composer appears unresponsive.
+ final loadingSheetBuilt = Completer();
+ loadingSheet =
+ showBuzzModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showCloseButton: false,
+ builder: (sheetContext) {
+ loadingSheetContext = sheetContext;
+ if (!loadingSheetBuilt.isCompleted) loadingSheetBuilt.complete();
+ return const SizedBox(
+ key: Key('ios-emoji-picker-palette-loading'),
+ height: 180,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(),
+ SizedBox(height: Grid.sm),
+ Text('Loading emoji…'),
+ ],
+ ),
+ ),
+ );
+ },
+ ).whenComplete(() {
+ if (leavingLoadingSheet) return;
+ loadingCancelled = true;
+ _iosEmojiPickerPresenting = false;
+ onDismiss?.call();
+ });
+
+ try {
+ customEmoji = await container.read(customEmojiPaletteProvider.future);
+ } catch (_) {
+ if (loadingCancelled) return;
+ await loadingSheetBuilt.future;
+ if (loadingCancelled) return;
+ leavingLoadingSheet = true;
+ if (loadingSheetContext case final sheetContext?
+ when sheetContext.mounted) {
+ Navigator.of(sheetContext).pop();
+ }
+ await loadingSheet;
+ // A palette fetch failure must not strand the composer's open state: fall
+ // back to the Flutter picker, which watches the palette itself.
+ _iosEmojiPickerPresenting = false;
+ if (context.mounted) {
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+ }
+ return;
+ }
+
+ if (loadingCancelled) return;
+ await loadingSheetBuilt.future;
+ if (loadingCancelled) return;
+ leavingLoadingSheet = true;
+ if (loadingSheetContext case final sheetContext?
+ when sheetContext.mounted) {
+ Navigator.of(sheetContext).pop();
+ }
+ await loadingSheet;
+ }
+ if (!context.mounted) {
+ _iosEmojiPickerPresenting = false;
+ return;
+ }
+ final recent = container.read(recentEmojiProvider);
+ final mediaAuth = container.read(mediaGetAuthServiceProvider);
+ final prefs = container.read(savedPrefsProvider);
+ final colors = context.colors;
+ var dismissed = false;
+
+ void finish() {
+ if (dismissed) return;
+ dismissed = true;
+ _iosEmojiPickerPresenting = false;
+ _nativeEmojiPickerChannel.setMethodCallHandler(null);
+ onDismiss?.call();
+ }
+
+ _nativeEmojiPickerChannel.setMethodCallHandler((call) async {
+ switch (call.method) {
+ case 'mediaHeaders':
+ final url = call.arguments;
+ return url is String
+ ? mediaAuth.headersFor(url)
+ : const {};
+ case 'selected':
+ final emoji = call.arguments;
+ if (emoji is String && emoji.isNotEmpty) onSelect(emoji);
+ return null;
+ case 'dismissed':
+ finish();
+ return null;
+ case 'skinToneChanged':
+ final value = call.arguments;
+ if (value is int) {
+ await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value));
+ }
+ return null;
+ }
+ });
+
+ try {
+ final presented = await _nativeEmojiPickerChannel.invokeMethod(
+ 'present',
+ {
+ 'customEmoji': [
+ for (final emoji in customEmoji)
+ {'shortcode': emoji.shortcode, 'url': emoji.url},
+ ],
+ 'recent': [for (final entry in recent) entry.emoji],
+ 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
+ 'surfaceColor': colors.surface.toARGB32(),
+ 'controlColor': colors.surfaceContainerHighest.toARGB32(),
+ 'textColor': colors.onSurface.toARGB32(),
+ 'secondaryTextColor': colors.onSurfaceVariant.toARGB32(),
+ 'accentColor': colors.primary.toARGB32(),
+ 'dividerColor': colors.outlineVariant.toARGB32(),
+ 'isDark': Theme.of(context).brightness == Brightness.dark,
+ },
+ );
+ if (presented == true) return;
+ } on MissingPluginException {
+ // Older builds keep the complete Flutter picker as a safe fallback.
+ } on PlatformException {
+ // A native presentation failure should not remove the emoji affordance.
+ }
+
+ if (dismissed || !context.mounted) {
+ // `dismissed` means finish() already released the guard; the unmounted
+ // path releases it here so a future open is not blocked.
+ _iosEmojiPickerPresenting = false;
+ return;
+ }
+ dismissed = true;
+ _iosEmojiPickerPresenting = false;
+ _nativeEmojiPickerChannel.setMethodCallHandler(null);
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+}
diff --git a/mobile/lib/features/channels/emoji_picker/search_field.dart b/mobile/lib/features/channels/emoji_picker/search_field.dart
index dd4c4ee923..c61dd9d2cc 100644
--- a/mobile/lib/features/channels/emoji_picker/search_field.dart
+++ b/mobile/lib/features/channels/emoji_picker/search_field.dart
@@ -8,68 +8,80 @@ part of '../emoji_picker.dart';
/// words and the OS mangles them. Flutter exposes the same switches directly.
class _EmojiSearchField extends StatelessWidget {
final TextEditingController controller;
+ final EdgeInsetsGeometry padding;
- const _EmojiSearchField({required this.controller});
+ const _EmojiSearchField({
+ required this.controller,
+ this.padding = const EdgeInsets.fromLTRB(
+ Grid.gutter,
+ 0,
+ Grid.gutter,
+ Grid.xxs,
+ ),
+ });
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Padding(
- padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs),
- child: TextField(
- key: const ValueKey('emoji-picker-search'),
- controller: controller,
- autocorrect: false,
- enableSuggestions: false,
- textCapitalization: TextCapitalization.none,
- textInputAction: TextInputAction.search,
- style: searchInputTextStyle.copyWith(color: colors.onSurface),
- decoration: InputDecoration(
- hintText: 'Search emoji',
- hintStyle: searchInputTextStyle.copyWith(
- color: colors.onSurfaceVariant,
- ),
- prefixIcon: Icon(
- LucideIcons.search,
- size: 18,
- color: colors.onSurfaceVariant,
- ),
- prefixIconConstraints: const BoxConstraints(
- minWidth: Grid.md,
- minHeight: Grid.md,
- ),
- suffixIcon: ValueListenableBuilder(
- valueListenable: controller,
- builder: (context, value, _) {
- if (value.text.isEmpty) return const SizedBox.shrink();
- return IconButton(
- key: const ValueKey('emoji-picker-search-clear'),
- onPressed: controller.clear,
- icon: Icon(
- LucideIcons.x,
- size: 16,
- color: colors.onSurfaceVariant,
- ),
- visualDensity: VisualDensity.compact,
- tooltip: 'Clear search',
- );
- },
- ),
- filled: true,
- fillColor: colors.surface,
- isDense: true,
- contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.outlineVariant),
- ),
- enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.outlineVariant),
- ),
- focusedBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.primary),
+ padding: padding,
+ child: SizedBox(
+ height: 44,
+ child: TextField(
+ key: const ValueKey('emoji-picker-search'),
+ controller: controller,
+ autocorrect: false,
+ enableSuggestions: false,
+ textCapitalization: TextCapitalization.none,
+ textInputAction: TextInputAction.search,
+ style: searchInputTextStyle.copyWith(color: colors.onSurface),
+ decoration: InputDecoration(
+ hintText: 'Search emoji',
+ hintStyle: searchInputTextStyle.copyWith(
+ color: colors.onSurfaceVariant,
+ ),
+ prefixIcon: Icon(
+ LucideIcons.search,
+ size: 18,
+ color: colors.onSurfaceVariant,
+ ),
+ prefixIconConstraints: const BoxConstraints(
+ minWidth: Grid.md,
+ minHeight: Grid.md,
+ ),
+ suffixIcon: ValueListenableBuilder(
+ valueListenable: controller,
+ builder: (context, value, _) {
+ if (value.text.isEmpty) return const SizedBox.shrink();
+ return IconButton(
+ key: const ValueKey('emoji-picker-search-clear'),
+ onPressed: controller.clear,
+ icon: Icon(
+ LucideIcons.x,
+ size: 16,
+ color: colors.onSurfaceVariant,
+ ),
+ visualDensity: VisualDensity.compact,
+ tooltip: 'Clear search',
+ );
+ },
+ ),
+ filled: true,
+ fillColor: colors.surface,
+ isDense: true,
+ contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.primary),
+ ),
),
),
),
diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart
index e09f9478a7..da2050802e 100644
--- a/mobile/lib/features/channels/message_actions.dart
+++ b/mobile/lib/features/channels/message_actions.dart
@@ -2,8 +2,10 @@ import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
+import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
@@ -37,11 +39,29 @@ import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
part 'message_actions/reaction_popover.dart';
+part 'message_actions/quick_reaction_row.dart';
+part 'message_actions/message_action_popover.dart';
+part 'message_actions/message_reaction_tray.dart';
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
+/// Presents the actions for [message] as an anchored popover when both
+/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet.
+///
+/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid
+/// until its future completes, and the returned image becomes this function's
+/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports
+/// whether the constrained layout actually renders the lifted preview;
+/// [onPopoverDismissed] runs after the route completes while [context] is still
+/// mounted. Neither callback runs for sheet fallback or a failed capture.
+///
+/// [composerFocusNode] remains caller-owned and must outlive the popover. If it
+/// has focus when this function is called, the popover unfocuses it and invokes
+/// [restoreComposerFocus] only after a dismissal with no selected action. The
+/// restorer must remain callable for the same lifetime and no-op if its composer
+/// is later disposed or replaced.
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
@@ -51,8 +71,13 @@ void showMessageActions({
List? allMessages,
String? currentPubkey,
bool isMember = false,
- bool isArchived = false,
Rect? anchorRect,
+ Future Function()? captureAnchorSnapshot,
+ ValueChanged? onPopoverPreviewVisibilityChanged,
+ VoidCallback? onPopoverDismissed,
+ FocusNode? composerFocusNode,
+ VoidCallback? restoreComposerFocus,
+ bool isArchived = false,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
@@ -67,6 +92,26 @@ void showMessageActions({
return;
}
+ if (_tryShowMessageActionsPopover(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ canManageMessage: canManageMessage,
+ allMessages: allMessages,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ anchorRect: anchorRect,
+ captureAnchorSnapshot: captureAnchorSnapshot,
+ onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
+ onPopoverDismissed: onPopoverDismissed,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
+ )) {
+ return;
+ }
+
showBuzzModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -485,9 +530,6 @@ class _FollowThreadTile extends ConsumerWidget {
}
}
-/// Promoted actions for the three dominant mobile jobs: respond now (Reply),
-/// hand off context (Copy link — the `buzz://message` link is the workspace's
-/// context-transfer primitive), and defer (Remind me).
class _FastActionsRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
@@ -638,127 +680,9 @@ class _FastActionTile extends StatelessWidget {
}
}
-/// The row of one-tap reactions at the top of the action sheet, plus the "+"
-/// tile that opens the full picker.
-///
/// The emoji shown are the user's own frequently-used set (desktop's
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
/// the row is full on a fresh install.
-class _QuickReactionRow extends ConsumerWidget {
- final TimelineMessage message;
-
- /// The sheet's context, popped before the reaction fires.
- final BuildContext sheetContext;
-
- /// The long-pressed message's page context — survives the sheet pop, so the
- /// picker opened from "+" isn't torn down with the sheet.
- final BuildContext pageContext;
-
- /// The long-pressed message's page ref. The picker callback outlives this
- /// bottom sheet, so it must not read through the sheet's disposed ref.
- final WidgetRef pageRef;
-
- /// Drives the staged glyph reveal when this row is shown in the popover.
- /// The bottom sheet leaves this null and retains its existing static row.
- final Animation? presentationAnimation;
-
- const _QuickReactionRow({
- required this.message,
- required this.sheetContext,
- required this.pageContext,
- required this.pageRef,
- this.presentationAnimation,
- });
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final customEmoji = ref.watch(customEmojiListProvider);
- final emoji = quickReactionEmoji(
- ref.watch(recentEmojiProvider),
- customShortcodes: {
- for (final entry in customEmoji) entry.shortcode.toLowerCase(),
- },
- );
- final customByShortcode = {
- for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
- };
-
- void react(String value) {
- // The generic picker is also used for composing and statuses. Record
- // recency here, at the reaction call site, so only reactions drive the
- // quick-reaction row.
- pageRef.read(recentEmojiProvider.notifier).record(value);
- // The sheet is on its way out, so the burst can't come from this tile —
- // hand it to the pill that's about to appear in the timeline.
- armReactionBurst(pageRef, message, value);
- pageRef.read(channelActionsProvider).addReaction(message.id, value);
- }
-
- return LayoutBuilder(
- builder: (context, constraints) {
- const desiredCircleSize = 52.0;
- const minimumCircleSize = 44.0;
- final itemCount = emoji.length + 1;
- final gapCount = itemCount - 1;
- final circleSize =
- ((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
- .clamp(minimumCircleSize, desiredCircleSize)
- .toDouble();
- final gap =
- ((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
- .clamp(0.0, Grid.twelve)
- .toDouble();
- final circles = [
- for (var index = 0; index < emoji.length; index++)
- _ReactionItemReveal(
- key: ValueKey('quick-reaction-${emoji[index]}'),
- animation: presentationAnimation,
- index: index,
- child: _QuickReactionCircle(
- size: circleSize,
- onTap: () {
- Navigator.of(sheetContext).pop();
- react(emoji[index]);
- },
- child: _QuickReactionGlyph(
- value: emoji[index],
- customByShortcode: customByShortcode,
- ),
- ),
- ),
- _ReactionItemReveal(
- key: const ValueKey('quick-reaction-more'),
- animation: presentationAnimation,
- index: emoji.length,
- child: _QuickReactionCircle(
- size: circleSize,
- onTap: () {
- Navigator.of(sheetContext).pop();
- showEmojiPicker(context: pageContext, onSelect: react);
- },
- child: Icon(
- LucideIcons.plus,
- size: 24,
- color: context.colors.onSurfaceVariant,
- ),
- ),
- ),
- ];
-
- return Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- for (var index = 0; index < circles.length; index++) ...[
- circles[index],
- if (index < circles.length - 1) SizedBox(width: gap),
- ],
- ],
- );
- },
- );
- }
-}
-
class _ReactionItemReveal extends StatelessWidget {
final Animation? animation;
final int index;
diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart
new file mode 100644
index 0000000000..350afff82b
--- /dev/null
+++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart
@@ -0,0 +1,999 @@
+part of '../message_actions.dart';
+
+const _messageActionRowHeight = 48.0;
+const _messageActionRowVerticalPadding = Grid.xxs;
+const _messageActionSeparatorHeight = 0.5;
+const _messageActionVerticalInset = Grid.half;
+const _messageActionMenuMaxWidth = 288.0;
+const _messageActionPreviewMaxWidth = 358.0;
+const _messageActionPreviewInset = Grid.xxs;
+const _messageActionGap = Grid.twelve;
+const _messageActionReactionSelection = '__reaction__';
+const _messageActionTransitionDuration = _reactionPopoverDuration;
+const _iosMessageActionTransitionDuration = Duration(milliseconds: 220);
+const _iosNativeMessageActionSurfaceChannel = MethodChannel(
+ 'buzz/native_message_action_surface',
+);
+
+bool _messageActionsPresentationInFlight = false;
+bool? _iosNativeMessageActionSurfaceSupported;
+
+Future _supportsIosNativeMessageActionSurface() async {
+ if (!Platform.isIOS) return false;
+ final cached = _iosNativeMessageActionSurfaceSupported;
+ if (cached != null) return cached;
+
+ try {
+ final supported =
+ await _iosNativeMessageActionSurfaceChannel.invokeMethod(
+ 'isSupported',
+ ) ??
+ false;
+ _iosNativeMessageActionSurfaceSupported = supported;
+ return supported;
+ } on MissingPluginException {
+ _iosNativeMessageActionSurfaceSupported = false;
+ return false;
+ } on PlatformException {
+ _iosNativeMessageActionSurfaceSupported = false;
+ return false;
+ }
+}
+
+bool _tryShowMessageActionsPopover({
+ required BuildContext context,
+ required WidgetRef ref,
+ required TimelineMessage message,
+ required String channelId,
+ required bool canManageMessage,
+ required List? allMessages,
+ required String? currentPubkey,
+ required bool isMember,
+ required bool isArchived,
+ required Rect? anchorRect,
+ required Future Function()? captureAnchorSnapshot,
+ required ValueChanged? onPopoverPreviewVisibilityChanged,
+ required VoidCallback? onPopoverDismissed,
+ required FocusNode? composerFocusNode,
+ required VoidCallback? restoreComposerFocus,
+}) {
+ if (anchorRect == null || captureAnchorSnapshot == null) return false;
+ final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false;
+ unawaited(
+ _showMessageActionsPopover(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ canManageMessage: canManageMessage,
+ allMessages: allMessages,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ anchorRect: anchorRect,
+ captureAnchorSnapshot: captureAnchorSnapshot,
+ onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
+ onPopoverDismissed: onPopoverDismissed,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
+ shouldRestoreComposerFocus: shouldRestoreComposerFocus,
+ ).then((shown) {
+ if (shown || !context.mounted) return;
+ showMessageActions(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ canManageMessage: canManageMessage,
+ allMessages: allMessages,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ );
+ }),
+ );
+ return true;
+}
+
+Future _showMessageActionsPopover({
+ required BuildContext context,
+ required WidgetRef ref,
+ required TimelineMessage message,
+ required String channelId,
+ required bool canManageMessage,
+ required List? allMessages,
+ required String? currentPubkey,
+ required bool isMember,
+ required bool isArchived,
+ required Rect anchorRect,
+ required Future Function() captureAnchorSnapshot,
+ required ValueChanged? onPopoverPreviewVisibilityChanged,
+ required VoidCallback? onPopoverDismissed,
+ required FocusNode? composerFocusNode,
+ required VoidCallback? restoreComposerFocus,
+ required bool shouldRestoreComposerFocus,
+}) async {
+ if (_messageActionsPresentationInFlight) return true;
+ _messageActionsPresentationInFlight = true;
+
+ try {
+ final actions = _buildPopoverMessageActions(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ canManageMessage: canManageMessage,
+ allMessages: allMessages,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ );
+ if (actions.isEmpty) return false;
+ final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
+ final isIos = defaultTargetPlatform == TargetPlatform.iOS;
+
+ unawaited(HapticFeedback.mediumImpact());
+
+ final ui.Image snapshot;
+ try {
+ snapshot = await captureAnchorSnapshot();
+ } catch (_) {
+ return false;
+ }
+ if (!context.mounted) {
+ snapshot.dispose();
+ return false;
+ }
+ final useIosNativeActionSurface = await nativeActionSurfaceSupport;
+ if (!context.mounted) {
+ snapshot.dispose();
+ return false;
+ }
+
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+ if (shouldRestoreComposerFocus) composerFocusNode!.unfocus();
+
+ String? selectedActionId;
+ final dialogRoute = RawDialogRoute(
+ barrierDismissible: true,
+ barrierLabel: 'Dismiss message actions',
+ barrierColor: Colors.transparent,
+ transitionDuration: reduceMotion
+ ? Duration.zero
+ : isIos
+ ? _iosMessageActionTransitionDuration
+ : _messageActionTransitionDuration,
+ transitionBuilder: (context, animation, secondaryAnimation, child) =>
+ child,
+ pageBuilder: (dialogContext, animation, secondaryAnimation) =>
+ _MessageActionsPopover(
+ anchorRect: anchorRect,
+ anchorSnapshot: snapshot,
+ animation: animation,
+ message: message,
+ pageContext: context,
+ pageRef: ref,
+ actions: actions,
+ useIosNativeActionSurface: useIosNativeActionSurface,
+ onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
+ ),
+ );
+ var routePushed = false;
+ try {
+ final popResult = Navigator.of(
+ context,
+ rootNavigator: true,
+ ).push(dialogRoute);
+ routePushed = true;
+ selectedActionId = await popResult;
+ } finally {
+ if (routePushed) await dialogRoute.completed;
+ snapshot.dispose();
+ if (context.mounted) onPopoverDismissed?.call();
+ }
+
+ for (final action in actions) {
+ if (action.id != selectedActionId) continue;
+ await Future.sync(action.onSelected);
+ break;
+ }
+ if (selectedActionId == null &&
+ shouldRestoreComposerFocus &&
+ context.mounted) {
+ restoreComposerFocus?.call();
+ }
+ return true;
+ } finally {
+ _messageActionsPresentationInFlight = false;
+ }
+}
+
+List<_PopoverMessageAction> _buildPopoverMessageActions({
+ required BuildContext context,
+ required WidgetRef ref,
+ required TimelineMessage message,
+ required String channelId,
+ required bool canManageMessage,
+ required List? allMessages,
+ required String? currentPubkey,
+ required bool isMember,
+ required bool isArchived,
+}) {
+ final actions = <_PopoverMessageAction>[];
+ final messages = allMessages;
+ final canRemind = ref.read(reminderServiceProvider) != null;
+
+ if (!message.isSystem) {
+ if (messages != null) {
+ actions.add(
+ _PopoverMessageAction(
+ id: 'reply',
+ title: 'Reply',
+ icon: LucideIcons.messageSquareReply,
+ group: _PopoverMessageActionGroup.primary,
+ onSelected: () {
+ if (!context.mounted) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: message,
+ allMessages: messages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+ actions.add(
+ _PopoverMessageAction(
+ id: 'copyLink',
+ title: 'Copy link',
+ icon: LucideIcons.link2,
+ group: _PopoverMessageActionGroup.utility,
+ onSelected: () {
+ if (!context.mounted) return;
+ copyToClipboard(
+ context,
+ messageLinkFor(message: message, channelId: channelId),
+ message: 'Message link copied',
+ );
+ },
+ ),
+ );
+ if (canRemind) {
+ actions.add(
+ _PopoverMessageAction(
+ id: 'remind',
+ title: 'Remind me',
+ icon: LucideIcons.clock,
+ group: _PopoverMessageActionGroup.utility,
+ onSelected: () {
+ if (!context.mounted) return;
+ showRemindMeLaterSheet(
+ context: Navigator.of(context, rootNavigator: true).context,
+ ref: ref,
+ target: ReminderTarget(
+ eventId: message.id,
+ channelId: channelId,
+ preview: message.content.characters
+ .take(_reminderPreviewLength)
+ .toString(),
+ authorPubkey: message.pubkey,
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ final readState = ref.read(readStateProvider);
+ if (readState.isReady) {
+ final unread = isMessageUnread(
+ readState,
+ channelId: channelId,
+ messageId: message.id,
+ createdAt: message.createdAt,
+ threadRootId: message.rootId,
+ );
+ actions.add(
+ _PopoverMessageAction(
+ id: unread ? 'markRead' : 'markUnread',
+ title: unread ? 'Mark read' : 'Mark unread',
+ icon: unread ? LucideIcons.mailCheck : LucideIcons.mailOpen,
+ group: _PopoverMessageActionGroup.primary,
+ onSelected: () {
+ final notifier = ref.read(readStateProvider.notifier);
+ if (unread) {
+ notifier.markContextRead(
+ msgContextKey(message.id),
+ message.createdAt,
+ );
+ } else {
+ notifier.markContextUnread(
+ msgContextKey(message.id),
+ channelId: channelId,
+ );
+ }
+ },
+ ),
+ );
+ }
+
+ final rootId = message.rootId ?? message.id;
+ final following = ref.read(threadFollowsProvider).isFollowing(rootId);
+ actions.add(
+ _PopoverMessageAction(
+ id: following ? 'unfollowThread' : 'followThread',
+ title: following ? 'Unfollow thread' : 'Follow thread',
+ icon: following ? LucideIcons.bellOff : LucideIcons.bellRing,
+ group: _PopoverMessageActionGroup.utility,
+ onSelected: () {
+ final notifier = ref.read(threadFollowsProvider.notifier);
+ if (following) {
+ notifier.unfollowThread(rootId);
+ } else {
+ notifier.followThread(rootId);
+ }
+ },
+ ),
+ );
+ actions.add(
+ _PopoverMessageAction(
+ id: 'copyText',
+ title: 'Copy text',
+ icon: LucideIcons.copy,
+ group: _PopoverMessageActionGroup.utility,
+ onSelected: () =>
+ Clipboard.setData(ClipboardData(text: message.content)),
+ ),
+ );
+ }
+
+ if (canManageMessage) {
+ actions.add(
+ _PopoverMessageAction(
+ id: 'edit',
+ title: 'Edit message',
+ icon: LucideIcons.pencil,
+ group: _PopoverMessageActionGroup.primary,
+ onSelected: () {
+ if (!context.mounted) return;
+ _showEditSheet(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ );
+ },
+ ),
+ );
+ actions.add(
+ _PopoverMessageAction(
+ id: 'delete',
+ title: 'Delete message',
+ icon: LucideIcons.trash2,
+ group: _PopoverMessageActionGroup.destructive,
+ destructive: true,
+ onSelected: () {
+ if (!context.mounted) return;
+ _confirmDelete(
+ context: context,
+ ref: ref,
+ channelId: channelId,
+ messageId: message.id,
+ );
+ },
+ ),
+ );
+ }
+
+ const actionOrder = {
+ 'reply': 0,
+ 'markRead': 1,
+ 'markUnread': 1,
+ 'edit': 2,
+ 'copyText': 3,
+ 'copyLink': 4,
+ 'remind': 5,
+ 'followThread': 6,
+ 'unfollowThread': 6,
+ 'delete': 7,
+ };
+ actions.sort(
+ (left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!),
+ );
+ return actions;
+}
+
+enum _PopoverMessageActionGroup { primary, utility, destructive }
+
+class _PopoverMessageAction {
+ final String id;
+ final String title;
+ final IconData icon;
+ final _PopoverMessageActionGroup group;
+ final bool destructive;
+ final FutureOr Function() onSelected;
+
+ const _PopoverMessageAction({
+ required this.id,
+ required this.title,
+ required this.icon,
+ required this.group,
+ required this.onSelected,
+ this.destructive = false,
+ });
+
+ String get iosSymbol => switch (id) {
+ 'reply' => 'arrowshape.turn.up.left',
+ 'markRead' => 'envelope.open',
+ 'markUnread' => 'envelope.badge',
+ 'edit' => 'pencil',
+ 'copyText' => 'doc.on.doc',
+ 'copyLink' => 'link',
+ 'remind' => 'clock',
+ 'followThread' => 'bell',
+ 'unfollowThread' => 'bell.slash',
+ 'delete' => 'trash',
+ _ => 'ellipsis',
+ };
+
+ Map toPlatformArguments() => {
+ 'id': id,
+ 'title': title,
+ 'symbol': iosSymbol,
+ 'group': group.name,
+ 'destructive': destructive,
+ };
+}
+
+class _IosNativeMessageActionSurface extends HookWidget {
+ final List<_PopoverMessageAction> actions;
+ final double rowHeight;
+ final ValueChanged onSelected;
+
+ const _IosNativeMessageActionSurface({
+ required this.actions,
+ required this.rowHeight,
+ required this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final viewId = useState(null);
+ useEffect(() {
+ final id = viewId.value;
+ if (id == null) return null;
+ final channel = MethodChannel('buzz/native_message_action_surface/$id');
+ channel.setMethodCallHandler((call) async {
+ if (call.method != 'selected' || call.arguments is! Map) return;
+ final actionId = (call.arguments as Map)['id'];
+ if (actionId is String) onSelected(actionId);
+ });
+ return () => channel.setMethodCallHandler(null);
+ }, [viewId.value, onSelected]);
+
+ return UiKitView(
+ key: const ValueKey('ios-native-message-action-surface'),
+ viewType: 'buzz/native_message_action_surface',
+ creationParams: {
+ 'actions': [for (final action in actions) action.toPlatformArguments()],
+ 'surfaceColor': context.colors.surface.toARGB32(),
+ 'foregroundColor': context.colors.onSurface.toARGB32(),
+ 'separatorColor': context.colors.outlineVariant.toARGB32(),
+ 'errorColor': context.colors.error.toARGB32(),
+ 'interfaceStyle': context.colors.brightness.name,
+ 'rowHeight': rowHeight,
+ },
+ creationParamsCodec: const StandardMessageCodec(),
+ onPlatformViewCreated: (id) => viewId.value = id,
+ );
+ }
+}
+
+class _MessageActionsPopover extends HookWidget {
+ final Rect anchorRect;
+ final ui.Image anchorSnapshot;
+ final Animation animation;
+ final TimelineMessage message;
+ final BuildContext pageContext;
+ final WidgetRef pageRef;
+ final List<_PopoverMessageAction> actions;
+ final bool useIosNativeActionSurface;
+ final ValueChanged? onPreviewVisibilityChanged;
+
+ const _MessageActionsPopover({
+ required this.anchorRect,
+ required this.anchorSnapshot,
+ required this.animation,
+ required this.message,
+ required this.pageContext,
+ required this.pageRef,
+ required this.actions,
+ required this.useIosNativeActionSurface,
+ required this.onPreviewVisibilityChanged,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final mediaQuery = MediaQuery.of(context);
+ final selectionStarted = useRef(false);
+
+ void select(Object? result, [VoidCallback? effect]) {
+ if (selectionStarted.value) return;
+ selectionStarted.value = true;
+ Navigator.of(context).pop(result);
+ effect?.call();
+ }
+
+ void selectAction(String actionId) => select(actionId);
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final safeLeft = mediaQuery.padding.left + Grid.xxs;
+ final safeRight =
+ constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
+ final safeTop = mediaQuery.padding.top + Grid.xxs;
+ final safeBottom =
+ constraints.maxHeight -
+ mediaQuery.padding.bottom -
+ mediaQuery.viewInsets.bottom -
+ Grid.xxs;
+ final availableWidth = math.max(1.0, safeRight - safeLeft);
+ final availableHeight = math.max(1.0, safeBottom - safeTop);
+ final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
+ final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth);
+ final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
+ final preferredMenuHeight = menuLayout.preferredHeight;
+ final minimumMenuHeight = math.min(
+ menuLayout.rowHeight,
+ availableHeight,
+ );
+ final showReactionTray =
+ availableHeight >=
+ _reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight;
+ final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0;
+ final trayGap = showReactionTray ? _messageActionGap : 0.0;
+ final heightAfterTray = availableHeight - trayHeight - trayGap;
+ final showPreview =
+ showReactionTray &&
+ heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0;
+ final previewMinimumHeight = showPreview ? 48.0 : 0.0;
+ final previewGap = showPreview ? _messageActionGap : 0.0;
+ final menuBudget = math.max(
+ minimumMenuHeight,
+ availableHeight -
+ trayHeight -
+ trayGap -
+ previewMinimumHeight -
+ previewGap,
+ );
+ final menuHeight = math.min(preferredMenuHeight, menuBudget);
+ final previewMaximumHeight = showPreview
+ ? math.max(
+ previewMinimumHeight,
+ availableHeight -
+ trayHeight -
+ trayGap -
+ menuHeight -
+ previewGap,
+ )
+ : 0.0;
+ final previewInsetExtent = _messageActionPreviewInset * 2;
+ final previewSize = showPreview
+ ? () {
+ final previewWidthRatio =
+ (math.min(_messageActionPreviewMaxWidth, availableWidth) -
+ previewInsetExtent) /
+ math.max(anchorRect.width, 1);
+ final previewHeightRatio =
+ (previewMaximumHeight - previewInsetExtent) /
+ math.max(anchorRect.height, 1);
+ final previewScale = math.min(
+ 1.0,
+ math.max(
+ 0.0,
+ math.min(previewWidthRatio, previewHeightRatio),
+ ),
+ );
+ return Size(
+ (anchorRect.width * previewScale) + previewInsetExtent,
+ (anchorRect.height * previewScale) + previewInsetExtent,
+ );
+ }()
+ : Size.zero;
+ final totalHeight =
+ trayHeight + trayGap + previewSize.height + previewGap + menuHeight;
+ final contentTop = math.max(safeTop, safeBottom - totalHeight);
+ final surfaceLeft =
+ safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2);
+ final trayRect = Rect.fromLTWH(
+ surfaceLeft,
+ contentTop,
+ trayWidth,
+ trayHeight,
+ );
+ final trayHostWidth = math.min(
+ trayWidth + _reactionTraySpringAllowance,
+ safeRight - surfaceLeft,
+ );
+ final previewRect = Rect.fromLTWH(
+ surfaceLeft,
+ trayRect.bottom + trayGap,
+ previewSize.width,
+ previewSize.height,
+ );
+ final menuRect = Rect.fromLTWH(
+ surfaceLeft,
+ previewRect.bottom + previewGap,
+ menuWidth,
+ menuHeight,
+ );
+ return _MessageActionPreviewVisibility(
+ visible: showPreview,
+ onChanged: onPreviewVisibilityChanged,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: BackdropFilter(
+ filter: ui.ImageFilter.blur(
+ sigmaX: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
+ sigmaY: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
+ ),
+ child: AnimatedBuilder(
+ animation: animation,
+ builder: (context, child) {
+ final opacity = Curves.easeOutCubic.transform(
+ animation.value,
+ );
+ return ColoredBox(
+ key: const ValueKey('message-actions-background'),
+ color: context.colors.inverseSurface.withValues(
+ alpha: 0.14 * opacity,
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ Positioned.fill(
+ child: GestureDetector(
+ key: const ValueKey('message-actions-backdrop'),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => select(null),
+ ),
+ ),
+ if (showPreview)
+ Positioned.fromRect(
+ rect: previewRect,
+ child: AnimatedBuilder(
+ animation: animation,
+ child: RepaintBoundary(
+ child: _LiftedMessagePreview(
+ anchorSnapshot: anchorSnapshot,
+ ),
+ ),
+ builder: (context, child) {
+ final movement =
+ (defaultTargetPlatform == TargetPlatform.iOS
+ ? Curves.easeOutCubic
+ : Curves.easeInOutCubic)
+ .transform(animation.value);
+ final sourceRect = anchorRect.inflate(
+ _messageActionPreviewInset,
+ );
+ final translation = Offset(
+ ui.lerpDouble(
+ sourceRect.left - previewRect.left,
+ 0,
+ movement,
+ )!,
+ ui.lerpDouble(
+ sourceRect.top - previewRect.top,
+ 0,
+ movement,
+ )!,
+ );
+ final scaleX = ui.lerpDouble(
+ sourceRect.width / previewRect.width,
+ 1,
+ movement,
+ )!;
+ final scaleY = ui.lerpDouble(
+ sourceRect.height / previewRect.height,
+ 1,
+ movement,
+ )!;
+ return Transform.translate(
+ offset: translation,
+ child: Transform(
+ alignment: Alignment.topLeft,
+ transform: Matrix4.diagonal3Values(scaleX, scaleY, 1),
+ child: child,
+ ),
+ );
+ },
+ ),
+ ),
+ if (showReactionTray)
+ Positioned(
+ left: trayRect.left,
+ top: trayRect.top,
+ width: trayHostWidth,
+ height: trayRect.height,
+ child: _MessageReactionTray(
+ animation: animation,
+ trayWidth: trayWidth,
+ message: message,
+ pageContext: pageContext,
+ pageRef: pageRef,
+ popResult: _messageActionReactionSelection,
+ onSelected: (result, effect) => select(result, effect),
+ ),
+ ),
+ Positioned.fromRect(
+ rect: menuRect,
+ child: AnimatedBuilder(
+ animation: animation,
+ child: useIosNativeActionSurface
+ ? _IosNativeMessageActionSurface(
+ actions: actions,
+ rowHeight: menuLayout.rowHeight,
+ onSelected: selectAction,
+ )
+ : _MessageActionSurface(
+ actions: actions,
+ onSelected: selectAction,
+ ),
+ builder: (context, child) {
+ final appearance = const Interval(
+ 0.08,
+ 0.82,
+ curve: Curves.easeOutCubic,
+ ).transform(animation.value);
+ final fadedChild = Opacity(
+ opacity: appearance,
+ child: child,
+ );
+ if (defaultTargetPlatform == TargetPlatform.iOS) {
+ return fadedChild;
+ }
+ return Transform.scale(
+ alignment: Alignment.topLeft,
+ scale: ui.lerpDouble(0.96, 1, appearance)!,
+ child: fadedChild,
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _MessageActionPreviewVisibility extends HookWidget {
+ final bool visible;
+ final ValueChanged? onChanged;
+ final Widget child;
+ const _MessageActionPreviewVisibility({
+ required this.visible,
+ required this.onChanged,
+ required this.child,
+ });
+ @override
+ Widget build(BuildContext context) {
+ useEffect(() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted) onChanged?.call(visible);
+ });
+ return null;
+ }, [visible, onChanged]);
+ return child;
+ }
+}
+
+class _LiftedMessagePreview extends StatelessWidget {
+ final ui.Image anchorSnapshot;
+
+ const _LiftedMessagePreview({required this.anchorSnapshot});
+
+ @override
+ Widget build(BuildContext context) {
+ return DecoratedBox(
+ key: const ValueKey('message-action-preview'),
+ decoration: BoxDecoration(
+ color: context.colors.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(Radii.md),
+ border: Border.all(
+ color: context.colors.outlineVariant.withValues(alpha: 0.7),
+ width: 0.5,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.18),
+ blurRadius: 18,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(_messageActionPreviewInset),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(Radii.xs),
+ child: RawImage(
+ image: anchorSnapshot,
+ fit: BoxFit.fill,
+ filterQuality: FilterQuality.medium,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _MessageActionSurface extends StatelessWidget {
+ final List<_PopoverMessageAction> actions;
+ final ValueChanged onSelected;
+
+ const _MessageActionSurface({
+ required this.actions,
+ required this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
+ return Material(
+ key: const ValueKey('message-action-surface'),
+ color: context.colors.surface,
+ surfaceTintColor: Colors.transparent,
+ elevation: 10,
+ shadowColor: Colors.black.withValues(alpha: 0.22),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(Radii.dialog),
+ side: BorderSide(
+ color: context.colors.outlineVariant.withValues(alpha: 0.55),
+ width: 0.5,
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: _messageActionVerticalInset,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var index = 0; index < actions.length; index++) ...[
+ if (index > 0 &&
+ actions[index - 1].group != actions[index].group)
+ Divider(
+ key: ValueKey(
+ 'message-action-divider-${actions[index].group.name}',
+ ),
+ height: _messageActionSeparatorHeight,
+ thickness: _messageActionSeparatorHeight,
+ indent: Grid.xs,
+ endIndent: Grid.xs,
+ ),
+ _MessageActionRow(
+ action: actions[index],
+ height: menuLayout.rowHeight,
+ onSelected: onSelected,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _MessageActionRow extends StatelessWidget {
+ final _PopoverMessageAction action;
+ final double height;
+ final ValueChanged onSelected;
+
+ const _MessageActionRow({
+ required this.action,
+ required this.height,
+ required this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final foreground = action.destructive
+ ? context.colors.error
+ : context.colors.onSurface;
+ return Semantics(
+ button: true,
+ label: action.title,
+ excludeSemantics: true,
+ child: InkWell(
+ key: ValueKey('message-action-${action.id}'),
+ onTap: () {
+ unawaited(HapticFeedback.lightImpact());
+ onSelected(action.id);
+ },
+ child: SizedBox(
+ height: height,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
+ child: Row(
+ children: [
+ SizedBox(
+ width: 32,
+ child: Center(
+ child: Icon(action.icon, size: 22, color: foreground),
+ ),
+ ),
+ const SizedBox(width: Grid.twelve),
+ Expanded(
+ child: Text(
+ action.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodyLarge?.copyWith(
+ color: foreground,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _MessageActionSurfaceLayout {
+ final double rowHeight, preferredHeight;
+
+ const _MessageActionSurfaceLayout({
+ required this.rowHeight,
+ required this.preferredHeight,
+ });
+
+ factory _MessageActionSurfaceLayout.from(
+ BuildContext context,
+ List<_PopoverMessageAction> actions,
+ ) {
+ final textPainter = TextPainter(
+ text: TextSpan(
+ text: 'Message action',
+ style: context.textTheme.bodyLarge,
+ ),
+ textDirection: Directionality.of(context),
+ textScaler: MediaQuery.textScalerOf(context),
+ maxLines: 1,
+ )..layout();
+ final rowHeight = math.max(
+ _messageActionRowHeight,
+ textPainter.height + (_messageActionRowVerticalPadding * 2),
+ );
+ textPainter.dispose();
+
+ var separatorCount = 0;
+ for (var index = 1; index < actions.length; index++) {
+ if (actions[index - 1].group != actions[index].group) separatorCount += 1;
+ }
+ final preferredHeight =
+ (_messageActionVerticalInset * 2) +
+ (actions.length * rowHeight) +
+ (separatorCount * _messageActionSeparatorHeight);
+ return _MessageActionSurfaceLayout(
+ rowHeight: rowHeight,
+ preferredHeight: preferredHeight,
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart
new file mode 100644
index 0000000000..2128178904
--- /dev/null
+++ b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart
@@ -0,0 +1,36 @@
+part of '../message_actions.dart';
+
+class _MessageReactionTray extends StatelessWidget {
+ final Animation animation;
+ final double trayWidth;
+ final TimelineMessage message;
+ final BuildContext pageContext;
+ final WidgetRef pageRef;
+ final Object popResult;
+ final void Function(Object? result, VoidCallback effect) onSelected;
+
+ const _MessageReactionTray({
+ required this.animation,
+ required this.trayWidth,
+ required this.message,
+ required this.pageContext,
+ required this.pageRef,
+ required this.popResult,
+ required this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return _AnimatedReactionTray(
+ trayKey: const ValueKey('message-action-reaction-tray'),
+ animation: animation,
+ trayWidth: trayWidth,
+ scaleAlignment: Alignment.bottomLeft,
+ message: message,
+ pageContext: pageContext,
+ pageRef: pageRef,
+ popResult: popResult,
+ onSelected: onSelected,
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart
new file mode 100644
index 0000000000..15a71c30fc
--- /dev/null
+++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart
@@ -0,0 +1,139 @@
+part of '../message_actions.dart';
+
+class _QuickReactionRow extends ConsumerWidget {
+ final TimelineMessage message;
+
+ /// The sheet's context, popped before the reaction fires.
+ final BuildContext sheetContext;
+
+ /// The long-pressed message's page context — survives the sheet pop, so the
+ /// picker opened from "+" isn't torn down with the sheet.
+ final BuildContext pageContext;
+
+ /// The long-pressed message's page ref. The picker callback outlives this
+ /// bottom sheet, so it must not read through the sheet's disposed ref.
+ final WidgetRef pageRef;
+
+ /// Drives the staged glyph reveal when this row is shown in the popover.
+ /// The bottom sheet leaves this null and retains its existing static row.
+ final Animation? presentationAnimation;
+
+ final Object? popResult;
+
+ /// Selects exactly one popover result and side effect. Bottom sheets leave
+ /// this null and retain their existing local dismissal behavior.
+ final void Function(Object? result, VoidCallback effect)? onSelected;
+
+ const _QuickReactionRow({
+ required this.message,
+ required this.sheetContext,
+ required this.pageContext,
+ required this.pageRef,
+ this.presentationAnimation,
+ this.popResult,
+ this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final customEmoji = ref.watch(customEmojiListProvider);
+ final emoji = quickReactionEmoji(
+ ref.watch(recentEmojiProvider),
+ customShortcodes: {
+ for (final entry in customEmoji) entry.shortcode.toLowerCase(),
+ },
+ );
+ final customByShortcode = {
+ for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
+ };
+
+ void react(String value) {
+ // The generic picker is also used for composing and statuses. Record
+ // recency here, at the reaction call site, so only reactions drive the
+ // quick-reaction row.
+ pageRef.read(recentEmojiProvider.notifier).record(value);
+ // The sheet is on its way out, so the burst can't come from this tile —
+ // hand it to the pill that's about to appear in the timeline.
+ armReactionBurst(pageRef, message, value);
+ pageRef.read(channelActionsProvider).addReaction(message.id, value);
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ const desiredCircleSize = 52.0;
+ const minimumCircleSize = 44.0;
+ final itemCount = emoji.length + 1;
+ final gapCount = itemCount - 1;
+ final circleSize =
+ ((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
+ .clamp(minimumCircleSize, desiredCircleSize)
+ .toDouble();
+ final gap =
+ ((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
+ .clamp(0.0, Grid.twelve)
+ .toDouble();
+ final circles = [
+ for (var index = 0; index < emoji.length; index++)
+ _ReactionItemReveal(
+ key: ValueKey('quick-reaction-${emoji[index]}'),
+ animation: presentationAnimation,
+ index: index,
+ child: _QuickReactionCircle(
+ size: circleSize,
+ onTap: () {
+ void effect() => react(emoji[index]);
+
+ final select = onSelected;
+ if (select != null) {
+ select(popResult, effect);
+ } else {
+ Navigator.of(sheetContext).pop(popResult);
+ effect();
+ }
+ },
+ child: _QuickReactionGlyph(
+ value: emoji[index],
+ customByShortcode: customByShortcode,
+ ),
+ ),
+ ),
+ _ReactionItemReveal(
+ key: const ValueKey('quick-reaction-more'),
+ animation: presentationAnimation,
+ index: emoji.length,
+ child: _QuickReactionCircle(
+ size: circleSize,
+ onTap: () {
+ void effect() =>
+ showEmojiPicker(context: pageContext, onSelect: react);
+
+ final select = onSelected;
+ if (select != null) {
+ select(popResult, effect);
+ } else {
+ Navigator.of(sheetContext).pop(popResult);
+ effect();
+ }
+ },
+ child: Icon(
+ LucideIcons.plus,
+ size: 24,
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ];
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ for (var index = 0; index < circles.length; index++) ...[
+ circles[index],
+ if (index < circles.length - 1) SizedBox(width: gap),
+ ],
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart
index dfbc127861..11ea608b70 100644
--- a/mobile/lib/features/channels/message_actions/reaction_popover.dart
+++ b/mobile/lib/features/channels/message_actions/reaction_popover.dart
@@ -41,7 +41,7 @@ void _showMessageReactionPopover({
);
}
-class _MessageReactionPopover extends StatelessWidget {
+class _MessageReactionPopover extends HookWidget {
final Rect anchorRect;
final EdgeInsets spotlightPadding;
final Animation animation;
@@ -61,6 +61,14 @@ class _MessageReactionPopover extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
+ final selectionStarted = useRef(false);
+
+ void select(Object? result, [VoidCallback? effect]) {
+ if (selectionStarted.value) return;
+ selectionStarted.value = true;
+ Navigator.of(context).pop(result);
+ effect?.call();
+ }
return LayoutBuilder(
builder: (context, constraints) {
@@ -125,7 +133,7 @@ class _MessageReactionPopover extends StatelessWidget {
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
- onTap: () => Navigator.of(context).pop(),
+ onTap: () => select(null),
),
),
Positioned(
@@ -133,73 +141,15 @@ class _MessageReactionPopover extends StatelessWidget {
left: left,
width: trayWidth + _reactionTraySpringAllowance,
height: _reactionTrayMaxHeight,
- child: AnimatedBuilder(
+ child: _AnimatedReactionTray(
+ trayKey: const ValueKey('reaction-popover-tray'),
animation: animation,
- child: SizedBox(
- width: trayWidth,
- height: _reactionTrayMaxHeight,
- child: Padding(
- padding: const EdgeInsets.all(Grid.xxs),
- child: _QuickReactionRow(
- message: message,
- sheetContext: context,
- pageContext: pageContext,
- pageRef: pageRef,
- presentationAnimation: animation,
- ),
- ),
- ),
- builder: (context, child) {
- final appearance = const Interval(
- 0.04,
- 0.23,
- curve: Curves.easeOutCubic,
- ).transform(animation.value);
- final expansion = const Interval(
- 0.16,
- 0.92,
- ).transform(animation.value);
- final springExpansion = _reactionSpringCurve.transform(
- expansion,
- );
- final width = lerpDouble(
- _reactionTrayMaxHeight,
- trayWidth,
- springExpansion,
- )!;
-
- return Opacity(
- opacity: appearance,
- child: Transform.scale(
- alignment: trayScaleAlignment,
- scale: lerpDouble(0.95, 1, appearance)!,
- child: Align(
- alignment: Alignment.centerLeft,
- child: SizedBox(
- key: const ValueKey('reaction-popover-tray'),
- width: width,
- height: _reactionTrayMaxHeight,
- child: Material(
- color: context.colors.surface,
- surfaceTintColor: Colors.transparent,
- elevation: 8,
- shadowColor: Colors.black.withValues(alpha: 0.2),
- shape: const StadiumBorder(),
- clipBehavior: Clip.antiAlias,
- child: OverflowBox(
- alignment: Alignment.centerLeft,
- minWidth: trayWidth,
- maxWidth: trayWidth,
- minHeight: _reactionTrayMaxHeight,
- maxHeight: _reactionTrayMaxHeight,
- child: child,
- ),
- ),
- ),
- ),
- ),
- );
- },
+ trayWidth: trayWidth,
+ scaleAlignment: trayScaleAlignment,
+ message: message,
+ pageContext: pageContext,
+ pageRef: pageRef,
+ onSelected: (result, effect) => select(result, effect),
),
),
],
@@ -209,6 +159,99 @@ class _MessageReactionPopover extends StatelessWidget {
}
}
+class _AnimatedReactionTray extends StatelessWidget {
+ final Key trayKey;
+ final Animation animation;
+ final double trayWidth;
+ final AlignmentGeometry scaleAlignment;
+ final TimelineMessage message;
+ final BuildContext pageContext;
+ final WidgetRef pageRef;
+ final Object? popResult;
+ final void Function(Object? result, VoidCallback effect)? onSelected;
+
+ const _AnimatedReactionTray({
+ required this.trayKey,
+ required this.animation,
+ required this.trayWidth,
+ required this.scaleAlignment,
+ required this.message,
+ required this.pageContext,
+ required this.pageRef,
+ this.popResult,
+ this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: animation,
+ child: SizedBox(
+ width: trayWidth,
+ height: _reactionTrayMaxHeight,
+ child: Padding(
+ padding: const EdgeInsets.all(Grid.xxs),
+ child: _QuickReactionRow(
+ message: message,
+ sheetContext: context,
+ pageContext: pageContext,
+ pageRef: pageRef,
+ presentationAnimation: animation,
+ popResult: popResult,
+ onSelected: onSelected,
+ ),
+ ),
+ ),
+ builder: (context, child) {
+ final appearance = const Interval(
+ 0.04,
+ 0.23,
+ curve: Curves.easeOutCubic,
+ ).transform(animation.value);
+ final expansion = const Interval(0.16, 0.92).transform(animation.value);
+ final springExpansion = _reactionSpringCurve.transform(expansion);
+ final width = lerpDouble(
+ _reactionTrayMaxHeight,
+ trayWidth,
+ springExpansion,
+ )!;
+
+ return Opacity(
+ opacity: appearance,
+ child: Transform.scale(
+ alignment: scaleAlignment,
+ scale: lerpDouble(0.95, 1, appearance)!,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: SizedBox(
+ width: width,
+ height: _reactionTrayMaxHeight,
+ child: Material(
+ key: trayKey,
+ color: context.colors.surface,
+ surfaceTintColor: Colors.transparent,
+ elevation: 8,
+ shadowColor: Colors.black.withValues(alpha: 0.2),
+ shape: const StadiumBorder(),
+ clipBehavior: Clip.antiAlias,
+ child: OverflowBox(
+ alignment: Alignment.centerLeft,
+ minWidth: trayWidth,
+ maxWidth: trayWidth,
+ minHeight: _reactionTrayMaxHeight,
+ maxHeight: _reactionTrayMaxHeight,
+ child: child,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
class _OutsideAnchorClipper extends CustomClipper {
final Rect anchorRect;
final EdgeInsets spotlightPadding;
diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart
index d894e4a035..caf84815a3 100644
--- a/mobile/lib/features/channels/message_long_press_region.dart
+++ b/mobile/lib/features/channels/message_long_press_region.dart
@@ -1,111 +1,217 @@
-import 'dart:async';
+import 'dart:math' as math;
+import 'dart:ui' as ui;
+import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+const _iosMessageLongPressDuration = Duration(milliseconds: 200);
+const _maxMessageSnapshotDimension = 2048.0;
+
+double _messageSnapshotPixelRatio(Size size, double devicePixelRatio) {
+ final longestSide = size.longestSide;
+ if (!longestSide.isFinite || longestSide <= 0) return devicePixelRatio;
+ return math.min(devicePixelRatio, _maxMessageSnapshotDimension / longestSide);
+}
+
+/// Geometry and snapshot controls for a completed message long press.
+///
+/// Call [captureSnapshot] while the source is still mounted, then use
+/// [setSourceHidden] to hide it behind the lifted preview. Restore the source
+/// before the preview is disposed or dismissed.
+class MessageLongPressDetails {
+ /// The long-pressed source bounds in global logical coordinates.
+ final Rect anchorRect;
+
+ /// Captures the current source as an image for the lifted preview.
+ ///
+ /// The returned future can fail if the source is no longer mounted.
+ final Future Function() captureSnapshot;
+
+ /// Hides or reveals the mounted source while its preview is displayed.
+ final ValueChanged setSourceHidden;
+
+ /// Creates the details passed to a completed long-press callback.
+ const MessageLongPressDetails({
+ required this.anchorRect,
+ required this.captureSnapshot,
+ required this.setSourceHidden,
+ });
+}
+
/// An [InkWell] whose long press is observed above interactive descendants.
class MessageLongPressInkWell extends StatelessWidget {
final VoidCallback? onTap;
- final ValueChanged onLongPress;
+ final ValueChanged? onLongPress;
+
+ /// Handles a completed long press with snapshot and source-visibility access.
+ ///
+ /// When provided, this callback takes precedence over [onLongPress].
+ final ValueChanged? onLongPressDetails;
final BorderRadius? borderRadius;
final Color? highlightColor;
+
+ /// Identifies the [RepaintBoundary] to capture for the lifted preview.
+ ///
+ /// The key's current context must resolve to a boundary covering the message
+ /// content. When omitted, this widget inserts and owns that boundary.
+ final GlobalKey? snapshotKey;
final Widget child;
const MessageLongPressInkWell({
super.key,
this.onTap,
- required this.onLongPress,
+ this.onLongPress,
+ this.onLongPressDetails,
this.borderRadius,
this.highlightColor,
+ this.snapshotKey,
required this.child,
- });
+ }) : assert(onLongPress != null || onLongPressDetails != null);
@override
Widget build(BuildContext context) {
return _MessageLongPressRegion(
+ onTap: onTap,
onLongPress: onLongPress,
- child: InkWell(
- onTap: onTap,
- borderRadius: borderRadius,
- highlightColor: highlightColor,
- child: child,
- ),
+ onLongPressDetails: onLongPressDetails,
+ borderRadius: borderRadius,
+ highlightColor: highlightColor,
+ externalSnapshotKey: snapshotKey,
+ child: child,
);
}
}
-/// Detects a message long press without competing with interactive descendants.
+/// Detects a message long press through Flutter's gesture arena.
///
/// Links, media, reactions, and other nested controls keep their normal tap
-/// gestures. Moving far enough to scroll cancels the timer; recognizing the
-/// hold cancels the pointer so a descendant tap cannot fire on release.
+/// gestures, while a completed hold wins over descendant taps.
class _MessageLongPressRegion extends HookWidget {
- final ValueChanged onLongPress;
+ final VoidCallback? onTap;
+ final ValueChanged? onLongPress;
+ final ValueChanged? onLongPressDetails;
+ final BorderRadius? borderRadius;
+ final Color? highlightColor;
+ final GlobalKey? externalSnapshotKey;
final Widget child;
const _MessageLongPressRegion({
+ required this.onTap,
required this.onLongPress,
+ required this.onLongPressDetails,
+ required this.borderRadius,
+ required this.highlightColor,
+ required this.externalSnapshotKey,
required this.child,
});
@override
Widget build(BuildContext context) {
- final activePointer = useRef(null);
- final origin = useRef(null);
- final timer = useRef(null);
-
- void cancel() {
- timer.value?.cancel();
- timer.value = null;
- activePointer.value = null;
- origin.value = null;
- }
-
- useEffect(() => cancel, const []);
+ final fallbackSnapshotKey = useMemoized(GlobalKey.new, const []);
+ final snapshotKey = externalSnapshotKey ?? fallbackSnapshotKey;
+ final sourceHidden = useState(false);
void recognize() {
- final renderObject = context.findRenderObject();
- if (renderObject is! RenderBox || !renderObject.hasSize) return;
- onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
- }
+ final renderObject = snapshotKey.currentContext?.findRenderObject();
+ if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
+ return;
+ }
+ final anchorRect =
+ renderObject.localToGlobal(Offset.zero) & renderObject.size;
- void handlePointerDown(PointerDownEvent event) {
- if (activePointer.value != null) return;
- activePointer.value = event.pointer;
- origin.value = event.position;
- timer.value = Timer(kLongPressTimeout, () {
- final pointer = activePointer.value;
- if (pointer == null) return;
- timer.value = null;
- activePointer.value = null;
- origin.value = null;
- GestureBinding.instance.cancelPointer(pointer);
- recognize();
- });
- }
+ final detailsCallback = onLongPressDetails;
+ if (detailsCallback == null) {
+ onLongPress?.call(anchorRect);
+ return;
+ }
+ final maxSnapshotPixelRatio = math.min(
+ MediaQuery.devicePixelRatioOf(context),
+ 2.0,
+ );
- void handlePointerMove(PointerMoveEvent event) {
- if (event.pointer != activePointer.value) return;
- final start = origin.value;
- if (start == null) return;
- final delta = event.position - start;
- if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
- }
+ Future captureSnapshot() async {
+ RenderRepaintBoundary? boundary;
+ final renderObject = snapshotKey.currentContext?.findRenderObject();
+ if (renderObject is RenderRepaintBoundary && renderObject.hasSize) {
+ boundary = renderObject;
+ }
+ if (boundary == null) {
+ throw StateError('Message snapshot is unavailable');
+ }
+ final snapshotPixelRatio = _messageSnapshotPixelRatio(
+ boundary.size,
+ maxSnapshotPixelRatio,
+ );
+ try {
+ return await boundary.toImage(pixelRatio: snapshotPixelRatio);
+ } catch (_) {
+ await WidgetsBinding.instance.endOfFrame;
+ final retryBoundary = snapshotKey.currentContext?.findRenderObject();
+ if (retryBoundary is! RenderRepaintBoundary ||
+ !retryBoundary.hasSize) {
+ rethrow;
+ }
+ try {
+ return await retryBoundary.toImage(
+ pixelRatio: _messageSnapshotPixelRatio(
+ retryBoundary.size,
+ maxSnapshotPixelRatio,
+ ),
+ );
+ } catch (_) {
+ if (snapshotPixelRatio <= 1) rethrow;
+ return retryBoundary.toImage(
+ pixelRatio: _messageSnapshotPixelRatio(retryBoundary.size, 1),
+ );
+ }
+ }
+ }
- void handlePointerEnd(PointerEvent event) {
- if (event.pointer == activePointer.value) cancel();
+ void setSourceHidden(bool hidden) {
+ if (!context.mounted) return;
+ sourceHidden.value = hidden;
+ }
+
+ detailsCallback(
+ MessageLongPressDetails(
+ anchorRect: anchorRect,
+ captureSnapshot: captureSnapshot,
+ setSourceHidden: setSourceHidden,
+ ),
+ );
}
return Semantics(
onLongPress: recognize,
- child: Listener(
+ child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
- onPointerDown: handlePointerDown,
- onPointerMove: handlePointerMove,
- onPointerUp: handlePointerEnd,
- onPointerCancel: handlePointerEnd,
- child: child,
+ gestures: {
+ LongPressGestureRecognizer:
+ GestureRecognizerFactoryWithHandlers(
+ () => LongPressGestureRecognizer(
+ duration: defaultTargetPlatform == TargetPlatform.iOS
+ ? _iosMessageLongPressDuration
+ : null,
+ ),
+ (recognizer) {
+ recognizer.onLongPressStart = (_) => recognize();
+ },
+ ),
+ },
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: borderRadius,
+ highlightColor: highlightColor,
+ child: Opacity(
+ opacity: sourceHidden.value ? 0 : 1,
+ child: externalSnapshotKey == null
+ ? RepaintBoundary(key: snapshotKey, child: child)
+ : child,
+ ),
+ ),
),
);
}
diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart
index 6ab3ddd9b6..2988633158 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -43,6 +45,12 @@ part 'thread_detail_page/nested_thread_summary_row.dart';
part 'thread_detail_helpers.dart';
part 'thread_detail_page/tail_alignment.dart';
part 'thread_detail_page/thread_message.dart';
+part 'thread_detail_page/avatar.dart';
+
+const _landingHighlightDuration = Duration(seconds: 3);
+const _landingHighlightDelay = Duration(milliseconds: 50);
+const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
+const _landingHighlightOpacity = 0.12;
/// Full-screen thread detail page.
///
@@ -72,21 +80,28 @@ class ThreadDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final appView = View.of(context);
final composerDockHeight = useState(0.0);
+ final composerFocusNode = useFocusNode();
+ final restoreComposerFocus = useRef(null);
final settledImeBottomInset = useState(
usesFixedAndroidImeViewport
? appView.viewInsets.bottom / appView.devicePixelRatio
: 0.0,
);
+ useEffect(() {
+ final session = ref.read(relaySessionProvider.notifier);
+ return session.registerVisibleChannel(channelId);
+ }, [channelId]);
final sendMessage = ref.read(sendMessageProvider);
// Relay thread queries are keyed by the outermost root, even when this
// page displays a nested branch. Query that root, then select this head's
// direct children from the returned subtree below.
final queryRootId = threadHead.rootId ?? threadHead.id;
- final repliesState = ref.watch(
- threadRepliesWithLocalProvider(
- ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
- ),
+ final repliesArgs = ThreadRepliesArgs(
+ channelId: channelId,
+ rootId: queryRootId,
);
+ final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs));
+ final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs));
// The thread query is one-shot and asks only for content kinds, so a
// reaction, edit, or deletion that lands while the thread is open never
// reaches it — a new pill (and its burst) only showed up after leaving and
@@ -103,6 +118,12 @@ class ThreadDetailPage extends HookConsumerWidget {
});
final fetchedReplies = replyMessages.value;
+ // A terminal query error cannot produce a more authoritative list. Keep
+ // loading states provisional, but let the hydrated route snapshot drive
+ // the one-shot target jump when the relay query has definitively failed.
+ final canUseMessagesForInitialTarget =
+ relayReplyState.value != null ||
+ (relayReplyState.hasError && !relayReplyState.retrying);
final liveDeletionHidesHead = _isDeletedBy(
liveChannelEvents,
threadHead.id,
@@ -119,6 +140,68 @@ class ThreadDetailPage extends HookConsumerWidget {
threadHead,
...fetchedReplies,
];
+ final routeAnimation = ModalRoute.of(context)?.animation;
+ final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf(
+ context,
+ );
+ final highlightedMessageId = useState(null);
+ final initialTargetReadyForHighlight = useState(false);
+ useEffect(
+ () {
+ final messageId = initialMessageId;
+ if (messageId == null || !initialTargetReadyForHighlight.value) {
+ return null;
+ }
+ var disposed = false;
+ Timer? revealTimer;
+ Timer? dismissTimer;
+
+ void revealHighlight() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (disposed) return;
+ revealTimer = Timer(_landingHighlightDelay, () {
+ if (disposed) return;
+ highlightedMessageId.value = messageId;
+ dismissTimer = Timer(
+ _landingHighlightDuration +
+ (reducedLandingHighlightMotion
+ ? Duration.zero
+ : _landingHighlightTransitionDuration),
+ () {
+ if (!disposed) highlightedMessageId.value = null;
+ },
+ );
+ });
+ });
+ }
+
+ void handleRouteStatus(AnimationStatus status) {
+ if (status != AnimationStatus.completed) return;
+ routeAnimation?.removeStatusListener(handleRouteStatus);
+ revealHighlight();
+ }
+
+ if (routeAnimation == null ||
+ routeAnimation.status == AnimationStatus.completed) {
+ revealHighlight();
+ } else {
+ routeAnimation.addStatusListener(handleRouteStatus);
+ }
+
+ return () {
+ disposed = true;
+ routeAnimation?.removeStatusListener(handleRouteStatus);
+ revealTimer?.cancel();
+ dismissTimer?.cancel();
+ };
+ },
+ [
+ initialMessageId,
+ initialTargetReadyForHighlight.value,
+ reducedLandingHighlightMotion,
+ routeAnimation,
+ ],
+ );
// Index all messages by parentId so we can find direct children of any
// message and compute thread summaries for nested threads.
@@ -135,6 +218,7 @@ class ThreadDetailPage extends HookConsumerWidget {
final listViewport = useMemoized(LaidOutViewport.new);
useEffect(() => listViewport.dispose, [listViewport]);
final didJumpToInitialMessage = useRef(false);
+ final initialHighlightTargetIndex = useState(null);
final followsThreadTail = useRef(false);
final userOptedOutOfTailFollow = useRef(false);
final userDragDetachedTailFollow = useRef(false);
@@ -268,42 +352,84 @@ class ThreadDetailPage extends HookConsumerWidget {
);
}
- useEffect(() {
- final messageId = initialMessageId;
- // Wait for the authoritative thread query before consuming the one-shot
- // jump; the fallback main-timeline list can contain only the linked reply.
- if (messageId == null || fetchedReplies == null) return null;
- final chronologicalIndex = replies.indexWhere(
- (reply) => reply.id == messageId,
- );
- final targetIndex = messageId == threadHead.id
- ? headIndex
- : chronologicalIndex < 0
- ? null
- : indexForReply(chronologicalIndex);
- if (targetIndex == null || didJumpToInitialMessage.value) return null;
- didJumpToInitialMessage.value = true;
- initialTailSettle.abandon();
- userOptedOutOfTailFollow.value = true;
- userDragDetachedTailFollow.value = false;
- tailIntent.schedule(
- allowed: true,
- revalidate: () =>
- context.mounted &&
- itemScrollController.isAttached &&
- !tailIntent.isDragging,
- action: () {
- // The provisional route snapshot can make the linked reply look like
- // the tail. This authoritative deep-link jump intentionally leaves
- // the user at an older item, so it must opt out of follow-tail first.
- tailIntent.detach();
- followsThreadTail.value = false;
- isAtThreadTail.value = false;
- itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
- },
- );
- return null;
- }, [initialMessageId, fetchedReplies, replies.length]);
+ useEffect(
+ () {
+ final messageId = initialMessageId;
+ // Wait for either the authoritative thread query or a terminal query
+ // error before consuming the one-shot jump. During loading, the fallback
+ // main-timeline list can contain only the linked reply; after an error,
+ // that hydrated snapshot is the best available target list.
+ if (messageId == null || !canUseMessagesForInitialTarget) return null;
+ final chronologicalIndex = replies.indexWhere(
+ (reply) => reply.id == messageId,
+ );
+ final targetIndex = messageId == threadHead.id
+ ? headIndex
+ : chronologicalIndex < 0
+ ? null
+ : indexForReply(chronologicalIndex);
+ if (targetIndex == null || didJumpToInitialMessage.value) return null;
+ didJumpToInitialMessage.value = true;
+ initialTailSettle.abandon();
+ userOptedOutOfTailFollow.value = true;
+ userDragDetachedTailFollow.value = false;
+ tailIntent.schedule(
+ allowed: true,
+ revalidate: () =>
+ context.mounted &&
+ itemScrollController.isAttached &&
+ !tailIntent.isDragging,
+ action: () {
+ // The provisional route snapshot can make the linked reply look like
+ // the tail. This authoritative deep-link jump intentionally leaves
+ // the user at an older item, so it must opt out of follow-tail first.
+ tailIntent.detach();
+ followsThreadTail.value = false;
+ isAtThreadTail.value = false;
+ itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
+ initialHighlightTargetIndex.value = targetIndex;
+ },
+ );
+ return null;
+ },
+ [
+ initialMessageId,
+ canUseMessagesForInitialTarget,
+ fetchedReplies,
+ replies.length,
+ ],
+ );
+
+ useEffect(
+ () {
+ final targetIndex = initialHighlightTargetIndex.value;
+ if (targetIndex == null || initialTargetReadyForHighlight.value) {
+ return null;
+ }
+ var completionScheduled = false;
+ void markReadyAfterTargetLayout() {
+ if (completionScheduled ||
+ !itemPositionsListener.itemPositions.value.any(
+ (position) => position.index == targetIndex,
+ )) {
+ return;
+ }
+ completionScheduled = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted) initialTargetReadyForHighlight.value = true;
+ });
+ }
+
+ itemPositionsListener.itemPositions.addListener(
+ markReadyAfterTargetLayout,
+ );
+ markReadyAfterTargetLayout();
+ return () => itemPositionsListener.itemPositions.removeListener(
+ markReadyAfterTargetLayout,
+ );
+ },
+ [initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value],
+ );
// A top-anchored list doesn't stick to the newest item the way the old
// reversed one did, so follow the tail explicitly: when a reply arrives
@@ -630,11 +756,14 @@ class ThreadDetailPage extends HookConsumerWidget {
currentPubkey: currentPubkey,
showAuthor: true,
isHighlighted:
- liveHead.id == initialMessageId,
+ liveHead.id == highlightedMessageId.value,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
isThreadHead: true,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
Padding(
padding: const EdgeInsets.symmetric(
@@ -710,10 +839,14 @@ class ThreadDetailPage extends HookConsumerWidget {
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: showAuthor,
- isHighlighted: reply.id == initialMessageId,
+ isHighlighted:
+ reply.id == highlightedMessageId.value,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
if (nestedSummary != null)
_NestedThreadSummaryRow(
@@ -750,6 +883,9 @@ class ThreadDetailPage extends HookConsumerWidget {
_ThreadTypingIndicator(entries: threadTyping),
ComposeBar(
channelId: channelId,
+ focusNode: composerFocusNode,
+ onFocusRestorerChanged: (restoreFocus) =>
+ restoreComposerFocus.value = restoreFocus,
hintText: 'Reply in thread\u2026',
threadHeadId: threadHead.id,
rootId: effectiveRootId,
diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart
new file mode 100644
index 0000000000..502d6cffa8
--- /dev/null
+++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart
@@ -0,0 +1,28 @@
+part of '../thread_detail_page.dart';
+
+class _Avatar extends StatelessWidget {
+ final UserProfile? profile;
+ final String pubkey;
+
+ const _Avatar({required this.profile, required this.pubkey});
+
+ @override
+ Widget build(BuildContext context) {
+ final initial =
+ profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
+ final avatarUrl = profile?.avatarUrl;
+
+ return AvatarImage(
+ imageUrl: avatarUrl,
+ radius: messageAvatarSize / 2,
+ backgroundColor: context.colors.primaryContainer,
+ fallback: Text(
+ initial,
+ style: context.textTheme.labelMedium?.copyWith(
+ color: context.colors.onPrimaryContainer,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
index 248ccc9681..89bdd40998 100644
--- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart
+++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
@@ -1,6 +1,6 @@
part of '../thread_detail_page.dart';
-class _ThreadMessage extends ConsumerWidget {
+class _ThreadMessage extends HookConsumerWidget {
final TimelineMessage message;
final Map channelNames;
final String channelId;
@@ -10,6 +10,8 @@ class _ThreadMessage extends ConsumerWidget {
final List? allMessages;
final bool isMember;
final bool isArchived;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
/// Whether this is the message the thread hangs off, which keeps a standing
/// "+" where replies only get one once they carry a reaction.
@@ -26,10 +28,13 @@ class _ThreadMessage extends ConsumerWidget {
this.isMember = false,
this.isArchived = false,
this.isThreadHead = false,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
@@ -67,7 +72,7 @@ class _ThreadMessage extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);
- void openMessageActions(Rect anchorRect) {
+ void openMessageActions(MessageLongPressDetails details) {
showMessageActions(
context: context,
ref: ref,
@@ -78,18 +83,46 @@ class _ThreadMessage extends ConsumerWidget {
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
- anchorRect: anchorRect,
+ anchorRect: details.anchorRect,
+ captureAnchorSnapshot: details.captureSnapshot,
+ onPopoverPreviewVisibilityChanged: details.setSourceHidden,
+ onPopoverDismissed: () => details.setSourceHidden(false),
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
);
}
+ final reducedMotion = MediaQuery.disableAnimationsOf(context);
+ final highlightController = useAnimationController(
+ duration: _landingHighlightTransitionDuration,
+ );
+ final highlightProgress = useAnimation(highlightController);
+ useEffect(() {
+ if (reducedMotion) {
+ highlightController.value = isHighlighted ? 1 : 0;
+ } else {
+ unawaited(
+ highlightController.animateTo(
+ isHighlighted ? 1 : 0,
+ duration: _landingHighlightTransitionDuration,
+ curve: Curves.easeOutCubic,
+ ),
+ );
+ }
+ return null;
+ }, [highlightController, isHighlighted, reducedMotion]);
+ final highlightColor = highlightProgress == 0
+ ? Colors.transparent
+ : context.colors.primary.withValues(
+ alpha: _landingHighlightOpacity * highlightProgress,
+ );
+
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
- color: isHighlighted
- ? context.colors.primary.withValues(alpha: 0.12)
- : Colors.transparent,
+ color: highlightColor,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Material(
@@ -101,151 +134,174 @@ class _ThreadMessage extends ConsumerWidget {
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
- onLongPress: openMessageActions,
+ onLongPressDetails: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
+ snapshotKey: messageSnapshotKey,
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- if (showAuthor)
- GestureDetector(
- onTap: () =>
- showUserProfileSheet(context, message.pubkey),
- child: _Avatar(profile: profile, pubkey: message.pubkey),
- )
- else
- const SizedBox(width: messageAvatarSize),
- const SizedBox(width: messageAvatarContentGap),
- Expanded(
- child: Padding(
- padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (showAuthor)
- Padding(
- padding: const EdgeInsets.only(
- bottom: Grid.quarter,
- ),
- child: Row(
- children: [
- Expanded(
- child: MessageAuthorMeta(
- displayName: displayName,
- username: messageUsernameLabel(profile),
- timestamp: formatMessageTime(
- message.createdAt,
- ),
- nameColor: context.colors.onSurface,
- metadataColor:
- context.colors.onSurfaceVariant,
- onAuthorTap: () => showUserProfileSheet(
- context,
- message.pubkey,
- ),
- displayNameKey: ValueKey(
- 'thread-message-author-${message.id}',
- ),
- usernameKey: ValueKey(
- 'thread-message-username-${message.id}',
- ),
- timestampKey: ValueKey(
- 'thread-message-timestamp-${message.id}',
- ),
+ RepaintBoundary(
+ key: messageSnapshotKey,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ GestureDetector(
+ onTap: () =>
+ showUserProfileSheet(context, message.pubkey),
+ child: _Avatar(
+ profile: profile,
+ pubkey: message.pubkey,
+ ),
+ )
+ else
+ const SizedBox(width: messageAvatarSize),
+ const SizedBox(width: messageAvatarContentGap),
+ Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ top: showAuthor ? Grid.half : 0,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ Padding(
+ padding: const EdgeInsets.only(
+ bottom: Grid.quarter,
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: MessageAuthorMeta(
+ displayName: displayName,
+ username: messageUsernameLabel(
+ profile,
+ ),
+ timestamp: formatMessageTime(
+ message.createdAt,
+ ),
+ nameColor: context.colors.onSurface,
+ metadataColor:
+ context.colors.onSurfaceVariant,
+ onAuthorTap: () =>
+ showUserProfileSheet(
+ context,
+ message.pubkey,
+ ),
+ displayNameKey: ValueKey(
+ 'thread-message-author-${message.id}',
+ ),
+ usernameKey: ValueKey(
+ 'thread-message-username-${message.id}',
+ ),
+ timestampKey: ValueKey(
+ 'thread-message-timestamp-${message.id}',
+ ),
+ ),
+ ),
+ if (message.edited) ...[
+ const SizedBox(width: Grid.half),
+ Text(
+ '(edited)',
+ style: context.textTheme.labelSmall
+ ?.copyWith(
+ color: context
+ .colors
+ .onSurfaceVariant,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ],
),
),
- if (message.edited) ...[
- const SizedBox(width: Grid.half),
- Text(
- '(edited)',
- style: context.textTheme.labelSmall
- ?.copyWith(
- color:
- context.colors.onSurfaceVariant,
- fontStyle: FontStyle.italic,
- ),
- ),
- ],
- ],
- ),
- ),
- MessageContent(
- content: message.content,
- mentionNames: resolvedMentionNames,
- agentMentionPubkeys: agentMentionPubkeys,
- channelNames: channelNames,
- tags: message.tags,
- baseStyle: messageBodyTextStyle.copyWith(
- color: context.colors.onSurface,
- ),
- scaleEmojiOnly: true,
- mediaCarouselTrailingOverflow: Grid.gutter,
- onMediaReply: allMessages == null
- ? null
- : () {
- if (!context.mounted) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ThreadDetailPage(
- threadHead: message,
- allMessages: allMessages!,
- channelId: channelId,
- currentPubkey: currentPubkey,
- isMember: isMember,
- isArchived: isArchived,
- ),
+ MessageContent(
+ content: message.content,
+ mentionNames: resolvedMentionNames,
+ agentMentionPubkeys: agentMentionPubkeys,
+ channelNames: channelNames,
+ tags: message.tags,
+ baseStyle: messageBodyTextStyle.copyWith(
+ color: context.colors.onSurface,
+ ),
+ scaleEmojiOnly: true,
+ mediaCarouselTrailingOverflow: Grid.gutter,
+ onMediaReply: allMessages == null
+ ? null
+ : () {
+ if (!context.mounted) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: message,
+ allMessages: allMessages!,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ ),
+ ),
+ );
+ },
+ onMediaMore: (viewerContext, imageUrl) =>
+ showImageActions(
+ context: viewerContext,
+ ref: ref,
+ message: message,
+ channelId: channelId,
+ imageUrl: imageUrl,
+ canManageMessage: canManageMessage,
+ onDeleted: () {
+ if (viewerContext.mounted) {
+ Navigator.of(
+ viewerContext,
+ ).maybePop();
+ }
+ },
),
+ onChannelTap: (targetChannelId) {
+ openChannelLink(
+ context: context,
+ ref: ref,
+ channelId: targetChannelId,
+ currentChannelId: channelId,
);
},
- onMediaMore: (viewerContext, imageUrl) =>
- showImageActions(
- context: viewerContext,
- ref: ref,
- message: message,
- channelId: channelId,
- imageUrl: imageUrl,
- canManageMessage: canManageMessage,
- onDeleted: () {
- if (viewerContext.mounted) {
- Navigator.of(viewerContext).maybePop();
- }
- },
+ onMentionTap: (pubkey) =>
+ showUserProfileSheet(context, pubkey),
),
- onChannelTap: (targetChannelId) {
- openChannelLink(
- context: context,
- ref: ref,
- channelId: targetChannelId,
- currentChannelId: channelId,
- );
- },
- onMentionTap: (pubkey) =>
- showUserProfileSheet(context, pubkey),
- ),
- ReactionRow(
- messageId: message.id,
- reactions: message.reactions,
- onToggle: (emoji) =>
- toggleReaction(ref, message, emoji),
- showAddButton:
- isMember &&
- !isArchived &&
- (isThreadHead || message.reactions.isNotEmpty),
- onAddReaction: () => showAddReactionPicker(
- context: context,
- ref: ref,
- message: message,
+ ],
),
),
- ],
- ),
+ ),
+ ],
),
),
+ if (isThreadHead || message.reactions.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(
+ left: messageAvatarSize + messageAvatarContentGap,
+ ),
+ child: ReactionRow(
+ messageId: message.id,
+ reactions: message.reactions,
+ onToggle: (emoji) =>
+ toggleReaction(ref, message, emoji),
+ showAddButton: isMember && !isArchived,
+ onAddReaction: () => showAddReactionPicker(
+ context: context,
+ ref: ref,
+ message: message,
+ ),
+ ),
+ ),
],
),
),
@@ -255,30 +311,3 @@ class _ThreadMessage extends ConsumerWidget {
);
}
}
-
-class _Avatar extends StatelessWidget {
- final UserProfile? profile;
- final String pubkey;
-
- const _Avatar({required this.profile, required this.pubkey});
-
- @override
- Widget build(BuildContext context) {
- final initial =
- profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
- final avatarUrl = profile?.avatarUrl;
-
- return AvatarImage(
- imageUrl: avatarUrl,
- radius: messageAvatarSize / 2,
- backgroundColor: context.colors.primaryContainer,
- fallback: Text(
- initial,
- style: context.textTheme.labelMedium?.copyWith(
- color: context.colors.onPrimaryContainer,
- fontWeight: FontWeight.w600,
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index 7aabe8aed7..97687c4ed6 100644
--- a/mobile/lib/features/pairing/pairing_page.dart
+++ b/mobile/lib/features/pairing/pairing_page.dart
@@ -22,6 +22,7 @@ const _onboardingShellBottom = Color(0xFFD7E7F6);
const _onboardingCtaLabel = Color(0xFFD7E6F0);
const _onboardingInk = Color(0xFF111111);
const _onboardingMutedInk = Color(0xB3111111);
+const _onboardingErrorInk = Color(0xFF7A1025);
class PairingPage extends HookConsumerWidget {
/// When true, the pairing page is being used to add a new community
@@ -86,32 +87,22 @@ class PairingPage extends HookConsumerWidget {
}
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
- final themedSystemOverlayStyle =
- (context.theme.brightness == Brightness.dark
- ? SystemUiOverlayStyle.light
- : SystemUiOverlayStyle.dark)
- .copyWith(statusBarColor: Colors.transparent);
+ final onboardingSystemOverlayStyle = SystemUiOverlayStyle.dark.copyWith(
+ statusBarColor: Colors.transparent,
+ );
final pairingAppBar = addingCommunity
? AppBar(
- foregroundColor: isVerifyingSas
- ? context.colors.onSurface
- : _onboardingInk,
- systemOverlayStyle: isVerifyingSas
- ? themedSystemOverlayStyle
- : SystemUiOverlayStyle.dark.copyWith(
- statusBarColor: Colors.transparent,
- ),
+ foregroundColor: _onboardingInk,
+ systemOverlayStyle: onboardingSystemOverlayStyle,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
identityRecoveryOnly ? 'Send to Desktop' : 'Add Community',
- style: isVerifyingSas
- ? null
- : context.textTheme.titleMedium?.copyWith(
- color: _onboardingInk,
- ),
+ style: context.textTheme.titleMedium?.copyWith(
+ color: _onboardingInk,
+ ),
),
)
: null;
@@ -119,30 +110,33 @@ class PairingPage extends HookConsumerWidget {
final pairingScaffold = isVerifyingSas
? AnnotatedRegion(
key: const Key('pairing-sas-system-overlay'),
- value: themedSystemOverlayStyle,
- child: Scaffold(
- backgroundColor: context.colors.surface,
- appBar: pairingAppBar,
- body: SafeArea(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
- child: _SasVerificationView(
- sasCode: pairingState.sasCode ?? '------',
- confirmed: pairingState.userConfirmedSas,
- sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
- protectSensitiveActions:
- pairingState.protectSensitiveActions,
- biometricLabel: biometricProtectionLabel(
- defaultTargetPlatform,
- enrolledBiometrics.value ?? const [],
+ value: onboardingSystemOverlayStyle,
+ child: _OnboardingBackground(
+ child: Scaffold(
+ backgroundColor: Colors.transparent,
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
+ child: _SasVerificationView(
+ sasCode: pairingState.sasCode ?? '------',
+ confirmed: pairingState.userConfirmedSas,
+ sendsIdentityToDesktop:
+ pairingState.sendsIdentityToDesktop,
+ protectSensitiveActions:
+ pairingState.protectSensitiveActions,
+ biometricLabel: biometricProtectionLabel(
+ defaultTargetPlatform,
+ enrolledBiometrics.value ?? const [],
+ ),
+ errorMessage: pairingState.errorMessage,
+ onProtectionChanged: (value) => ref
+ .read(pairingProvider.notifier)
+ .setProtectSensitiveActions(value),
+ onConfirm: () =>
+ ref.read(pairingProvider.notifier).confirmSas(),
+ onDeny: () =>
+ ref.read(pairingProvider.notifier).denySas(),
),
- errorMessage: pairingState.errorMessage,
- onProtectionChanged: (value) => ref
- .read(pairingProvider.notifier)
- .setProtectSensitiveActions(value),
- onConfirm: () =>
- ref.read(pairingProvider.notifier).confirmSas(),
- onDeny: () => ref.read(pairingProvider.notifier).denySas(),
),
),
),
@@ -182,6 +176,7 @@ class PairingPage extends HookConsumerWidget {
);
final appSurface = PopScope(
+ key: const Key('pairing-pop-scope'),
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
ref.read(pairingProvider.notifier).reset();
@@ -206,6 +201,10 @@ class PairingPage extends HookConsumerWidget {
/// SAS verification screen shown during NIP-AB pairing.
class _SasVerificationView extends StatelessWidget {
+ static const _digitSize = 54.0;
+ static const _digitGap = 6.0;
+ static const _digitGroupGap = 14.0;
+
final String sasCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
@@ -230,65 +229,68 @@ class _SasVerificationView extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return Column(
- mainAxisAlignment: MainAxisAlignment.center,
+ final verificationContent = Column(
+ mainAxisSize: MainAxisSize.min,
children: [
- const Spacer(flex: 2),
-
- Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
- const SizedBox(height: Grid.sm),
-
- Text('Verify Security Code', style: context.textTheme.headlineSmall),
- const SizedBox(height: Grid.xs),
-
Text(
- confirmed
- ? 'Waiting for desktop to confirm...'
- : 'Does your desktop app show this code?',
+ 'Confirm desktop code',
textAlign: TextAlign.center,
- style: context.textTheme.bodyMedium?.copyWith(
- color: context.colors.onSurfaceVariant,
+ style: context.textTheme.headlineSmall?.copyWith(
+ color: _onboardingInk,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.4,
),
),
-
- const SizedBox(height: Grid.lg),
-
- // Large SAS code display
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
- decoration: BoxDecoration(
- color: context.colors.primaryContainer.withValues(alpha: 0.3),
- borderRadius: BorderRadius.circular(16),
- border: Border.all(
- color: context.colors.primary.withValues(alpha: 0.3),
- width: 2,
- ),
- ),
- child: Text(
- '${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
- style: context.textTheme.displayMedium?.copyWith(
- fontFamily: 'GeistMono',
- fontWeight: FontWeight.w700,
- letterSpacing: 8,
- color: context.colors.primary,
- ),
- ),
- ),
-
- const SizedBox(height: Grid.lg),
-
+ const SizedBox(height: Grid.xxs),
Text(
sendsIdentityToDesktop
- ? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.'
- : 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
+ ? 'Make sure the six-digit code matches on both devices. Your full Buzz identity will transfer to the desktop and grant it permanent access. Only continue if you started this recovery.'
+ : 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
textAlign: TextAlign.center,
- style: context.textTheme.bodySmall?.copyWith(
- color: context.colors.onSurfaceVariant,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: _onboardingMutedInk,
+ ),
+ ),
+ const SizedBox(height: Grid.md),
+ Semantics(
+ label:
+ 'Confirmation code ${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
+ child: ExcludeSemantics(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var index = 0; index < sasCode.length; index++) ...[
+ if (index > 0)
+ SizedBox(width: index == 3 ? _digitGroupGap : _digitGap),
+ Container(
+ key: Key('pairing-sas-code-digit-${index + 1}'),
+ width: _digitSize,
+ padding: const EdgeInsets.symmetric(vertical: Grid.xs),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.7),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: context.colors.primary.withValues(alpha: 0.15),
+ ),
+ ),
+ child: Text(
+ sasCode[index],
+ style: context.textTheme.displaySmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: _onboardingInk,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
),
),
-
const SizedBox(height: Grid.sm),
-
if (!sendsIdentityToDesktop)
CheckboxListTile(
key: const Key('protect-sensitive-actions-checkbox'),
@@ -296,67 +298,103 @@ class _SasVerificationView extends StatelessWidget {
onChanged: confirmed
? null
: (value) => onProtectionChanged(value ?? false),
+ activeColor: _onboardingInk,
+ checkColor: _onboardingCtaLabel,
+ side: const BorderSide(color: _onboardingInk),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
- title: Text(biometricLabel),
- subtitle: const Text('For secure actions'),
+ title: Text(
+ biometricLabel,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: _onboardingInk,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ subtitle: Text(
+ 'For secure actions',
+ style: context.textTheme.bodySmall?.copyWith(
+ color: _onboardingMutedInk,
+ ),
+ ),
),
-
if (errorMessage != null) ...[
const SizedBox(height: Grid.xs),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
- color: context.colors.error,
+ color: _onboardingErrorInk,
),
),
],
+ ],
+ );
- const SizedBox(height: Grid.lg),
-
- // Confirm / Deny buttons
- if (confirmed)
- Row(
+ final verificationActions = confirmed
+ ? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BuzzLoadingIndicator(
size: 24,
- color: context.colors.primary,
+ color: _onboardingInk,
semanticLabel: 'Connecting',
),
const SizedBox(width: Grid.twelve),
Text(
'Confirmed — waiting for desktop',
style: context.textTheme.bodySmall?.copyWith(
- color: context.colors.onSurfaceVariant,
+ color: _onboardingMutedInk,
),
),
],
)
- else
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Expanded(
- child: OutlinedButton.icon(
- onPressed: onDeny,
- icon: const Icon(LucideIcons.x),
- label: const Text('Cancel'),
- ),
- ),
- const SizedBox(width: Grid.sm),
- Expanded(
- child: FilledButton.icon(
+ : SizedBox(
+ width: double.infinity,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ FilledButton.icon(
+ style: _onboardingButtonStyle,
onPressed: onConfirm,
icon: const Icon(LucideIcons.check),
- label: const Text('Codes Match'),
+ label: const Text('Codes match'),
),
- ),
- ],
- ),
+ const SizedBox(height: Grid.xxs),
+ TextButton(
+ style: _onboardingSecondaryButtonStyle.copyWith(
+ minimumSize: const WidgetStatePropertyAll(
+ Size.fromHeight(48),
+ ),
+ ),
+ onPressed: onDeny,
+ child: const Text('Cancel'),
+ ),
+ ],
+ ),
+ );
- const Spacer(flex: 3),
+ return Column(
+ children: [
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final verticalPadding = Grid.sm * 2;
+ final minimumContentHeight =
+ constraints.maxHeight > verticalPadding
+ ? constraints.maxHeight - verticalPadding
+ : 0.0;
+ return SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(vertical: Grid.sm),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: minimumContentHeight),
+ child: Center(child: verificationContent),
+ ),
+ );
+ },
+ ),
+ ),
+ verificationActions,
+ const SizedBox(height: Grid.sm),
],
);
}
diff --git a/mobile/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart
index 4a9802f0da..f5c71adc17 100644
--- a/mobile/lib/shared/widgets/bee_refresh_indicator.dart
+++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart
@@ -385,10 +385,9 @@ class BeeRefreshIndicator extends HookConsumerWidget {
width: _beeWidth,
color: context.colors.primary,
flapAmount: flapAmount,
- eyeProgress:
- !isLoading &&
- !showEyeEmoji &&
- pupilProgress > 0
+ eyeProgress: showEyeEmoji
+ ? 1
+ : !isLoading && pupilProgress > 0
? pupilProgress
: null,
),
diff --git a/mobile/lib/shared/widgets/flapping_bee.dart b/mobile/lib/shared/widgets/flapping_bee.dart
index 9a99bd8796..a9057b60f2 100644
--- a/mobile/lib/shared/widgets/flapping_bee.dart
+++ b/mobile/lib/shared/widgets/flapping_bee.dart
@@ -137,7 +137,9 @@ class _FlappingBeePainter extends CustomPainter {
canvas.drawPath(finishedMark, Paint()..color = color);
if (eyeProgress case final progress?) {
- final pupilRadius = 20 * progress.clamp(0.0, 1.0);
+ // The eye cutouts are 54px wide. A full pupil must reach their 27px
+ // radius so the emoji-eye overlay never exposes the background beneath.
+ final pupilRadius = 27 * progress.clamp(0.0, 1.0);
final pupilPaint = Paint()..color = color;
canvas
..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint)
diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart
index 94d040526d..ad878b309e 100644
--- a/mobile/test/features/activity/activity_page_test.dart
+++ b/mobile/test/features/activity/activity_page_test.dart
@@ -684,6 +684,10 @@ void main() {
expect(page.channel.id, 'ch1');
expect(page.initialThreadRootId, 'parent-reply');
expect(page.initialMessageId, 'reply-event');
+ expect(
+ page.initialThreadRouteBehavior,
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ );
});
testWidgets('thread filter matches grouped thread replies', (tester) async {
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 9b9ac596c7..f5d54c257a 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -187,12 +187,20 @@ Widget _buildTestable({
String? canvasContent,
String? initialMessageId,
String? initialThreadRootId,
+ InitialThreadRouteBehavior initialThreadRouteBehavior =
+ InitialThreadRouteBehavior.push,
Map> threadReplies = const {},
Map>> pendingThreadReplies = const {},
+ Map> Function()> threadReplyLoaders =
+ const {},
+ Map> localThreadReplies = const {},
TextScaler textScaler = TextScaler.noScaling,
bool disableAnimations = false,
+ bool disableRetries = false,
+ Duration? Function(int retryCount, Object error)? providerRetry,
RelaySessionNotifier? relaySessionNotifier,
http.Client? mediaClient,
+ Widget? home,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -200,6 +208,7 @@ Widget _buildTestable({
final fakeMessagesNotifier =
messagesNotifier ?? _FakeMessagesNotifier(messages);
return ProviderScope(
+ retry: providerRetry ?? (disableRetries ? (_, _) => null : null),
overrides: [
channelMessagesProvider(
_channelId,
@@ -240,6 +249,19 @@ Widget _buildTestable({
threadRepliesProvider(
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
).overrideWith((ref) => entry.value),
+ for (final entry in threadReplyLoaders.entries)
+ threadRepliesProvider(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ ).overrideWith((ref) => entry.value()),
+ for (final entry in localThreadReplies.entries)
+ threadLocalRepliesProvider(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ ).overrideWith(
+ () => _FakeThreadLocalRepliesNotifier(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ entry.value,
+ ),
+ ),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
@@ -265,11 +287,14 @@ Widget _buildTestable({
child: child!,
),
navigatorObservers: navigatorObservers,
- home: ChannelDetailPage(
- channel: resolvedChannel,
- initialMessageId: initialMessageId,
- initialThreadRootId: initialThreadRootId,
- ),
+ home:
+ home ??
+ ChannelDetailPage(
+ channel: resolvedChannel,
+ initialMessageId: initialMessageId,
+ initialThreadRootId: initialThreadRootId,
+ initialThreadRouteBehavior: initialThreadRouteBehavior,
+ ),
),
);
}
@@ -1478,8 +1503,19 @@ void main() {
);
await tester.pumpAndSettle();
- expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
- expect(find.byType(BottomSheet), findsOneWidget);
+ expect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-surface')),
+ findsOneWidget,
+ );
+ expect(find.byType(BottomSheet), findsNothing);
expect(find.text('Copy text'), findsOneWidget);
});
@@ -4423,6 +4459,372 @@ void main() {
});
group('Deep-link navigation', () {
+ testWidgets('fades the target highlight in after the thread route lands', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Target reply',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final timelineMessages = formatTimeline([root, target]);
+ final threadHead = timelineMessages.firstWhere(
+ (message) => message.id == root.id,
+ );
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ threadReplies: {
+ 'root': [target],
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ ),
+ child: const Text('Open highlighted thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open highlighted thread'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 1));
+
+ final threadRoute =
+ ModalRoute.of(tester.element(find.byType(ThreadDetailPage)))!
+ as MaterialPageRoute;
+ expect(threadRoute.animation!.status, AnimationStatus.forward);
+ final transitionDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(transitionDecoration.color, Colors.transparent);
+
+ await tester.pump(threadRoute.transitionDuration);
+ expect(threadRoute.animation!.status, AnimationStatus.completed);
+ await tester.pump();
+ await tester.pump();
+ final landedDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final enteringDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(enteringDecoration.color!.a, greaterThan(0));
+ expect(enteringDecoration.color!.a, lessThan(0.12));
+
+ await tester.pump(const Duration(milliseconds: 150));
+ final visibleDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(visibleDecoration.color!.a, closeTo(0.12, 0.001));
+ });
+
+ testWidgets('waits for a delayed target jump before highlighting', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final replies = [
+ for (var i = 0; i < 40; i++)
+ _textMsg(
+ id: 'reply-$i',
+ pubkey: 'bob',
+ content: 'Reply $i',
+ createdAt: 1100 + i,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ ),
+ ];
+ final timelineMessages = formatTimeline([root, ...replies]);
+ final threadHead = timelineMessages.first;
+ final replyCompleter = Completer>();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root],
+ pendingThreadReplies: {'root': replyCompleter.future},
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'reply-30',
+ ),
+ ),
+ ),
+ child: const Text('Open delayed thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open delayed thread'));
+ await tester.pumpAndSettle();
+ await tester.pump(const Duration(seconds: 4));
+
+ expect(
+ find.byKey(const ValueKey('thread-message-group-reply-30')),
+ findsNothing,
+ );
+
+ replyCompleter.complete(replies);
+ await tester.pumpAndSettle();
+
+ final target = find.byKey(const ValueKey('thread-message-reply-30'));
+ expect(target, findsOneWidget);
+ final landedDecoration =
+ tester.widget(target).decoration as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final enteringDecoration =
+ tester.widget(target).decoration as BoxDecoration;
+ expect(enteringDecoration.color!.a, greaterThan(0));
+ expect(enteringDecoration.color!.a, lessThan(0.12));
+ });
+
+ testWidgets('waits for a retry before jumping to a hydrated target', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Hydrated target',
+ createdAt: 1400,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final earlierReplies = [
+ for (var i = 0; i < 30; i++)
+ _textMsg(
+ id: 'reply-$i',
+ pubkey: 'bob',
+ content: 'Reply $i',
+ createdAt: 1100 + i,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ ),
+ ];
+ final timelineMessages = formatTimeline([root, target]);
+ final firstAttempt = Completer>();
+ var attempts = 0;
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ providerRetry: (retryCount, _) =>
+ retryCount == 0 ? const Duration(seconds: 30) : null,
+ localThreadReplies: {
+ 'root': [target],
+ },
+ threadReplyLoaders: {
+ 'root': () {
+ attempts++;
+ if (attempts == 1) return firstAttempt.future;
+ return Future.value([...earlierReplies, target]);
+ },
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: ThreadDetailPage(
+ threadHead: timelineMessages.first,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ );
+ await tester.pump();
+ firstAttempt.completeError(Exception('transient thread query failure'));
+ await tester.pump();
+ await tester.pump();
+
+ final targetFinder = find.byKey(const ValueKey('thread-message-target'));
+ expect(targetFinder, findsOneWidget);
+ final retryingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(retryingDecoration.color, Colors.transparent);
+ expect(attempts, 1);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final stillRetryingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(stillRetryingDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 2800));
+ expect(attempts, 1);
+ final expiredJumpDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(expiredJumpDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(seconds: 30));
+ await tester.pumpAndSettle();
+
+ expect(attempts, 2);
+ expect(
+ find.byKey(const ValueKey('thread-message-group-target')),
+ findsOneWidget,
+ );
+ final landedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final highlightedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(highlightedDecoration.color!.a, greaterThan(0));
+ });
+
+ testWidgets('highlights a hydrated target after the thread query fails', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Hydrated target',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final timelineMessages = formatTimeline([root, target]);
+ final replyCompleter = Completer>();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ pendingThreadReplies: {'root': replyCompleter.future},
+ disableRetries: true,
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: ThreadDetailPage(
+ threadHead: timelineMessages.first,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final targetFinder = find.byKey(const ValueKey('thread-message-target'));
+ expect(targetFinder, findsOneWidget);
+ final loadingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(loadingDecoration.color, Colors.transparent);
+
+ replyCompleter.completeError(Exception('thread query failed'));
+ for (var i = 0; i < 8; i++) {
+ await tester.pump();
+ }
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+
+ final highlightedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(highlightedDecoration.color!.a, greaterThan(0));
+ expect(highlightedDecoration.color!.a, lessThan(0.12));
+ });
+
testWidgets('opens a nested reply in its direct-parent thread', (
tester,
) async {
@@ -4481,7 +4883,147 @@ void main() {
find.byKey(const ValueKey('thread-message-target')),
);
final decoration = highlighted.decoration as BoxDecoration;
- expect(decoration.color, isNot(Colors.transparent));
+ final initialHighlight = decoration.color!;
+ expect(initialHighlight, isNot(Colors.transparent));
+ expect(initialHighlight.a, closeTo(0.12, 0.001));
+
+ await tester.pump(const Duration(milliseconds: 2999));
+ final heldDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(heldDecoration.color, initialHighlight);
+
+ await tester.pump(const Duration(milliseconds: 1));
+ await tester.pump(const Duration(milliseconds: 150));
+
+ final fadingDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(fadingDecoration.color!.a, greaterThan(0));
+ expect(fadingDecoration.color!.a, lessThan(initialHighlight.a));
+
+ await tester.pump(const Duration(milliseconds: 150));
+ final dismissedDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(dismissedDecoration.color, Colors.transparent);
+ });
+
+ testWidgets('does not replace a newer route after delayed hydration', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ );
+ final messagesNotifier = _FakeMessagesNotifier(const []);
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: const [],
+ messagesNotifier: messagesNotifier,
+ initialThreadRootId: 'root',
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final navigator = Navigator.of(
+ tester.element(find.byType(ChannelDetailPage)),
+ );
+ messagesNotifier.setMessages([root]);
+ navigator.push(
+ MaterialPageRoute(
+ builder: (_) => const Scaffold(body: Text('New destination')),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('New destination'), findsOneWidget);
+ expect(find.byType(ThreadDetailPage), findsNothing);
+ });
+
+ testWidgets('replaces a temporary channel route for an initial thread', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Target reply',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final relaySession = _TrackingRelaySession();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ relaySessionNotifier: relaySession,
+ threadReplies: {
+ 'root': [target],
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ChannelDetailPage(
+ channel: _testChannel,
+ initialMessageId: 'target',
+ initialThreadRootId: 'root',
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ ),
+ ),
+ ),
+ child: const Text('Open activity thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open activity thread'));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ThreadDetailPage), findsOneWidget);
+ expect(find.byType(ChannelDetailPage), findsNothing);
+ expect(relaySession.visibleChannels, [_testChannel.id]);
+
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+
+ expect(find.text('Open activity thread'), findsOneWidget);
+ expect(find.byType(ChannelDetailPage), findsNothing);
+ expect(relaySession.visibleChannels, isEmpty);
});
});
@@ -6214,7 +6756,9 @@ void main() {
const ValueKey('thread-message-group-reply-29'),
);
final composer = find.byKey(const ValueKey('composer-surface'));
- await tester.drag(list, const Offset(0, 24));
+ // Clear the gesture arena's touch slop so this represents a deliberate
+ // tail-detaching drag rather than a long-press hold with small motion.
+ await tester.drag(list, const Offset(0, 48));
await tester.pumpAndSettle();
expect(
tester.getBottomLeft(latest).dy,
@@ -7279,6 +7823,15 @@ Channel _channel({required String id, required String name}) => Channel(
isMember: true,
);
+class _FakeThreadLocalRepliesNotifier extends ThreadLocalRepliesNotifier {
+ final List _replies;
+
+ _FakeThreadLocalRepliesNotifier(super.args, this._replies);
+
+ @override
+ List build() => _replies;
+}
+
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
List _messages;
bool _hasLoadedMessages;
@@ -7330,6 +7883,27 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
AsyncError('Connection failed', StackTrace.current);
}
+class _TrackingRelaySession extends RelaySessionNotifier {
+ final visibleChannels = [];
+
+ @override
+ SessionState build() =>
+ const SessionState(status: SessionStatus.disconnected);
+
+ @override
+ void Function() registerVisibleChannel(String channelId) {
+ final release = super.registerVisibleChannel(channelId);
+ visibleChannels.add(channelId);
+ var released = false;
+ return () {
+ if (released) return;
+ released = true;
+ visibleChannels.remove(channelId);
+ release();
+ };
+ }
+}
+
class _ReconnectingRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index 1270bc386c..775c9f4f71 100644
--- a/mobile/test/features/channels/compose_bar_test.dart
+++ b/mobile/test/features/channels/compose_bar_test.dart
@@ -182,6 +182,9 @@ Widget _buildComposeBar({
RelayConfigNotifier Function()? relayConfig,
PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(),
VoidCallback? onFocusRequested,
+ FocusNode? focusNode,
+ ValueChanged? onFocusRestorerChanged,
+ String composeBarKey = 'compose-bar',
}) {
return ProviderScope(
overrides: [
@@ -229,7 +232,10 @@ Widget _buildComposeBar({
child: Align(
alignment: Alignment.bottomCenter,
child: ComposeBar(
+ key: ValueKey(composeBarKey),
channelId: 'channel-1',
+ focusNode: focusNode,
+ onFocusRestorerChanged: onFocusRestorerChanged,
onFocusRequested: onFocusRequested,
onSend: onSend,
),
@@ -560,6 +566,184 @@ void main() {
);
});
+ testWidgets('uses a parent-owned focus node when provided', (tester) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ focusNode: focusNode,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+
+ await tester.tap(find.text('Message\u2026'));
+ await tester.pump();
+ await tester.pump();
+
+ expect(focusNode.hasFocus, isTrue);
+ expect(
+ tester.widget(find.byType(TextField)).focusNode,
+ same(focusNode),
+ );
+ });
+
+ testWidgets('restores the collapsed editor before requesting focus', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ VoidCallback? restoreFocus;
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ focusNode: focusNode,
+ onFocusRestorerChanged: (callback) => restoreFocus = callback,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+
+ await tester.tap(find.text('Message\u2026'));
+ await tester.pump();
+ await tester.pump();
+ focusNode.unfocus();
+ await tester.pump();
+ await tester.pumpAndSettle();
+ expect(find.byType(TextField), findsNothing);
+
+ restoreFocus!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(find.byType(TextField), findsOneWidget);
+ expect(focusNode.hasFocus, isTrue);
+ });
+
+ testWidgets('keeps hook order when the parent focus node changes', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ focusNode: focusNode,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('invalidates a registered focus restorer on unmount', (
+ tester,
+ ) async {
+ final callbacks = [];
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onFocusRestorerChanged: callbacks.add,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+ final registeredRestorer = callbacks.single;
+
+ await tester.pumpWidget(const SizedBox.shrink());
+
+ registeredRestorer();
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('does not let an old restorer mutate a replacement composer', (
+ tester,
+ ) async {
+ final callbacks = [];
+ await tester.pumpWidget(
+ _buildComposeBar(
+ composeBarKey: 'first-compose-bar',
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onFocusRestorerChanged: callbacks.add,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+ final oldRestorer = callbacks.single;
+ await tester.pumpWidget(
+ _buildComposeBar(
+ composeBarKey: 'second-compose-bar',
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onFocusRestorerChanged: callbacks.add,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+ expect(callbacks, hasLength(2));
+
+ oldRestorer();
+ await tester.pump();
+ await tester.pump();
+ expect(find.byType(TextField), findsNothing);
+
+ callbacks.last();
+ await tester.pump();
+ await tester.pump();
+ expect(find.byType(TextField), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ });
+
testWidgets('starts Android composer motion with the first IME metrics', (
tester,
) async {
diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart
index bf8086da4c..9c1cf55616 100644
--- a/mobile/test/features/channels/emoji_picker_test.dart
+++ b/mobile/test/features/channels/emoji_picker_test.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:buzz/features/channels/emoji_picker.dart';
import 'package:buzz/features/channels/recent_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
@@ -6,9 +8,13 @@ import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:nostr/nostr.dart' as nostr;
import '../../helpers/widget_helpers.dart';
@@ -41,11 +47,39 @@ final _dataset = () {
),
_entry(
'point_up',
- native: '\u{261D}\u{1F3FD}',
+ native: '\u{261D}\u{1F3FB}',
categoryId: 'people',
name: 'Index Pointing Up',
skinIndex: 1,
),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FC}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 2,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FD}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 3,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FE}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 4,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FF}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 5,
+ ),
];
final nature = [
_entry(
@@ -95,6 +129,49 @@ final _tallDataset = () {
const _customEmoji = [
CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'),
];
+const _relayCustomEmoji = [
+ CustomEmoji(
+ shortcode: 'buzzbee',
+ url: 'https://relay.example/media/buzzbee.png',
+ ),
+];
+
+class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier {
+ _FakeCustomEmojiPaletteNotifier(this.palette);
+
+ final Future> palette;
+
+ @override
+ Future> build() => palette;
+}
+
+const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
+
+void _setMockNativeEmojiPickerHandler(
+ Future Function(MethodCall call)? handler,
+) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler);
+}
+
+Future _sendNativeEmojiPickerCall(
+ WidgetTester tester,
+ String method, [
+ Object? arguments,
+]) async {
+ final response = Completer();
+ await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
+ _nativeEmojiPickerChannel.name,
+ _nativeEmojiPickerChannel.codec.encodeMethodCall(
+ MethodCall(method, arguments),
+ ),
+ response.complete,
+ );
+ final envelope = await response.future;
+ return envelope == null
+ ? null
+ : _nativeEmojiPickerChannel.codec.decodeEnvelope(envelope);
+}
Future _prefs() {
SharedPreferences.setMockInitialValues({});
@@ -147,26 +224,113 @@ void main() {
// shortcut into it, not a page switcher.
final grid = find.byKey(const ValueKey('emoji-picker-grid'));
expect(grid, findsOneWidget);
+ final sectionKeys = tester
+ .widget(grid)
+ .slivers
+ .whereType()
+ .map((sliver) => sliver.key);
expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-grinning')),
+ sectionKeys,
+ containsAllInOrder(const [
+ ValueKey('emoji-section-people'),
+ ValueKey('emoji-section-nature'),
+ ValueKey('emoji-section-custom'),
+ ]),
+ );
+ });
+
+ testWidgets(
+ 'search shares the sheet header with the shared close control',
+ (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final search = tester.getRect(
+ find.byKey(const ValueKey('emoji-picker-search')),
+ );
+ final close = tester.getRect(find.byTooltip('Close sheet'));
+
+ expect(close.size, const Size.square(44));
+ expect(search.center.dy, close.center.dy);
+ expect(close.left - search.right, Grid.xxs);
+ },
+ );
+
+ testWidgets('the Flutter picker keeps the established tray height', (
+ tester,
+ ) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final picker = find.byType(EmojiPickerSheet);
+ final context = tester.element(picker);
+ expect(
+ tester.getSize(picker).height,
+ closeTo(MediaQuery.sizeOf(context).height * 0.62, 0.5),
+ );
+ expect(find.byType(DraggableScrollableSheet), findsNothing);
+ });
+
+ testWidgets('the Flutter search field is a full pill', (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final field = tester.widget(
+ find.byKey(const ValueKey('emoji-picker-search')),
+ );
+ final border = field.decoration!.border! as OutlineInputBorder;
+ expect(border.borderRadius, BorderRadius.circular(Radii.full));
+ });
+
+ testWidgets('uses the shared sheet surface instead of a picker override', (
+ tester,
+ ) async {
+ final prefs = await _prefs();
+ final theme = AppTheme.light().copyWith(
+ colorScheme: lightColorScheme.copyWith(
+ surfaceContainerHighest: Colors.grey,
),
- findsOneWidget,
+ bottomSheetTheme: const BottomSheetThemeData(
+ backgroundColor: Colors.green,
+ ),
+ );
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
+ customEmojiListProvider.overrideWithValue(_customEmoji),
+ ],
+ child: MaterialApp(
+ theme: theme,
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () =>
+ showEmojiPicker(context: context, onSelect: (_) {}),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pumpAndSettle();
+
+ final ancestorMaterials = find
+ .ancestor(
+ of: find.byType(EmojiPickerSheet),
+ matching: find.byType(Material),
+ )
+ .evaluate()
+ .map((element) => element.widget as Material);
+ expect(
+ ancestorMaterials.map((material) => material.color),
+ contains(Colors.green),
);
expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-fire')),
- ),
- findsOneWidget,
- );
- expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
- ),
- findsOneWidget,
+ ancestorMaterials.map((material) => material.color),
+ isNot(contains(Colors.grey)),
);
});
@@ -174,21 +338,27 @@ void main() {
await _pumpPicker(tester, prefs: await _prefs());
// The rail used to be a short left-aligned strip. Every section now gets
- // one evenly-sized slot across the same width the search field spans.
- final searchField = tester.getRect(
- find.byKey(const ValueKey('emoji-picker-search')),
- );
+ // one evenly-sized slot across the tray, while search shares its row with
+ // the close control above.
+ final picker = tester.getRect(find.byType(EmojiPickerSheet));
final people = tester.getRect(find.byTooltip('Smileys & People'));
final nature = tester.getRect(find.byTooltip('Animals & Nature'));
final custom = tester.getRect(find.byTooltip('Custom'));
+ final skinTone = tester.getRect(find.byTooltip('Skin tone'));
expect(nature.left, greaterThan(people.left));
expect(custom.left, greaterThan(nature.left));
expect(people.width, closeTo(nature.width, 0.5));
expect(people.width, closeTo(custom.width, 0.5));
- // First slot starts and last slot ends on the search field's edges.
- expect(people.left, closeTo(searchField.left, 0.5));
- expect(custom.right, closeTo(searchField.right, 0.5));
+ expect(people.width, closeTo(skinTone.width, 0.5));
+ expect(people.left, closeTo(picker.left + Grid.gutter, 0.5));
+ expect(skinTone.right, closeTo(picker.right - Grid.gutter, 0.5));
+ expect(
+ tester.getSize(
+ find.byKey(const ValueKey('emoji-skin-tone-dot-selected')),
+ ),
+ const Size.square(16),
+ );
});
testWidgets('tapping the rail scrolls the grid instead of replacing it', (
@@ -211,6 +381,37 @@ void main() {
expect(offset(), closeTo(28 + 25 * 40, 0.5));
});
+ testWidgets(
+ 'a bottom-clamped final section stays highlighted after a rail tap',
+ (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
+ final colors = Theme.of(
+ tester.element(find.byType(EmojiPickerSheet)),
+ ).colorScheme;
+ Color iconColor(String tooltip) => tester
+ .widget(
+ find.descendant(
+ of: find.byTooltip(tooltip),
+ matching: find.byType(Icon),
+ ),
+ )
+ .color!;
+
+ await tester.tap(find.byTooltip('Custom'));
+ await tester.pumpAndSettle();
+
+ final grid = tester.widget(
+ find.byKey(const ValueKey('emoji-picker-grid')),
+ );
+ expect(
+ grid.controller!.offset,
+ closeTo(grid.controller!.position.maxScrollExtent, 0.5),
+ );
+ expect(iconColor('Custom'), colors.primary);
+ expect(iconColor('Animals & Nature'), colors.onSurfaceVariant);
+ },
+ );
+
testWidgets('the custom section only exists when the palette has emoji', (
tester,
) async {
@@ -223,6 +424,8 @@ void main() {
await _pumpPicker(tester, prefs: await _prefs());
expect(find.byTooltip('Custom'), findsOneWidget);
+ await tester.tap(find.byTooltip('Custom'));
+ await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
findsOneWidget,
@@ -236,14 +439,16 @@ void main() {
// A community's own emoji used to get their own looser 6-per-row grid,
// which read as a different component bolted onto the sheet.
- final native = tester.getRect(
- find.byKey(const ValueKey('emoji-tile-fire')),
- );
+ final nativeWidth = tester
+ .getRect(find.byKey(const ValueKey('emoji-tile-grinning')))
+ .width;
+ await tester.tap(find.byTooltip('Custom'));
+ await tester.pumpAndSettle();
final custom = tester.getRect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
);
- expect(custom.width, closeTo(native.width, 0.5));
- expect(custom.height, closeTo(native.height, 0.5));
+ expect(custom.width, closeTo(nativeWidth, 0.5));
+ expect(custom.height, closeTo(40, 0.5));
});
testWidgets('typing filters across the standard and custom sets', (
@@ -323,27 +528,116 @@ void main() {
expect(find.byTooltip('Smileys & People'), findsOneWidget);
});
+ testWidgets(
+ 'changing skin tone keeps the scrolled-to category highlighted',
+ (tester) async {
+ // A skin-tone change rebuilds the sections and the active-section
+ // notifier. Regression: the notifier was recreated at index 0, so a
+ // user parked on a later category snapped back to the first one in the
+ // rail while the grid stayed put. The notifier now seeds from the live
+ // scroll offset, so the highlight survives the rebuild.
+ await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
+ final colors = Theme.of(
+ tester.element(find.byType(EmojiPickerSheet)),
+ ).colorScheme;
+ Color iconColor(String tooltip) => tester
+ .widget(
+ find.descendant(
+ of: find.byTooltip(tooltip),
+ matching: find.byType(Icon),
+ ),
+ )
+ .color!;
+
+ await tester.tap(find.byTooltip('Animals & Nature'));
+ await tester.pumpAndSettle();
+ expect(iconColor('Animals & Nature'), colors.primary);
+ expect(iconColor('Smileys & People'), colors.onSurfaceVariant);
+
+ await tester.tap(find.byTooltip('Skin tone'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3')));
+ await tester.pumpAndSettle();
+
+ // Still on Nature after the tone rebuild — not reset to People.
+ expect(iconColor('Animals & Nature'), colors.primary);
+ expect(iconColor('Smileys & People'), colors.onSurfaceVariant);
+ },
+ );
+
testWidgets('a standard emoji emits its glyph', (tester) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
+ await tester.tap(find.byTooltip('Animals & Nature'));
+ await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
await tester.pumpAndSettle();
expect(selected, ['\u{1F525}']);
});
- testWidgets('a skin-tone variant is selectable', (tester) async {
+ testWidgets('skin tone choice shows and emits one selected variant', (
+ tester,
+ ) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
- await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up-1')));
+ expect(find.byKey(const ValueKey('emoji-tile-point_up')), findsOneWidget);
+ expect(find.byKey(const ValueKey('emoji-tile-point_up-1')), findsNothing);
+
+ await tester.tap(find.byTooltip('Skin tone'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up')));
await tester.pumpAndSettle();
expect(selected, ['\u{261D}\u{1F3FD}']);
});
+ testWidgets('skin tone choices paint their colors on a solid layer', (
+ tester,
+ ) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ await tester.tap(find.byTooltip('Skin tone'));
+ await tester.pumpAndSettle();
+
+ const expectedColors = [
+ Color(0xFFFFC93A),
+ Color(0xFFFFDAB7),
+ Color(0xFFE7B98F),
+ Color(0xFFC88C61),
+ Color(0xFFA46134),
+ Color(0xFF5D4437),
+ ];
+ for (final (index, expectedColor) in expectedColors.indexed) {
+ final decorations = tester
+ .widgetList(
+ find.descendant(
+ of: find.byKey(ValueKey('emoji-skin-tone-dot-$index')),
+ matching: find.byType(DecoratedBox),
+ ),
+ )
+ .map((widget) => widget.decoration)
+ .whereType();
+
+ expect(
+ decorations.any(
+ (decoration) =>
+ decoration.color == expectedColor &&
+ decoration.gradient == null &&
+ decoration.backgroundBlendMode == null,
+ ),
+ isTrue,
+ );
+ }
+ });
+
testWidgets('a custom emoji emits :shortcode:', (tester) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
+ await tester.tap(find.byTooltip('Custom'));
+ await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
);
@@ -358,6 +652,8 @@ void main() {
final prefs = await _prefs();
final selected = await _pumpPicker(tester, prefs: prefs);
+ await tester.tap(find.byTooltip('Animals & Nature'));
+ await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
await tester.pumpAndSettle();
@@ -392,6 +688,422 @@ void main() {
});
});
+ group('iOS native emoji picker', () {
+ setUp(resetIosEmojiPickerPresentationForTest);
+
+ testWidgets('passes shared theme and custom emoji into the native sheet', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ final mediaAuth = MediaGetAuthService(
+ baseUrl: 'https://relay.example',
+ nsec: nostr.Keys.generate().nsec,
+ );
+ MethodCall? presentation;
+ final selected = [];
+ var dismissals = 0;
+ _setMockNativeEmojiPickerHandler((call) async {
+ presentation = call;
+ return true;
+ });
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ customEmojiPaletteProvider.overrideWith(
+ () => _FakeCustomEmojiPaletteNotifier(
+ Future.value([..._customEmoji, ..._relayCustomEmoji]),
+ ),
+ ),
+ mediaGetAuthServiceProvider.overrideWithValue(mediaAuth),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () => showEmojiPicker(
+ context: context,
+ onSelect: selected.add,
+ onDismiss: () => dismissals += 1,
+ ),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pump();
+
+ expect(presentation?.method, 'present');
+ final arguments = presentation?.arguments as Map;
+ expect(arguments['surfaceColor'], lightColorScheme.surface.toARGB32());
+ expect(arguments['skinTone'], 0);
+ expect(arguments['customEmoji'], [
+ {
+ 'shortcode': 'partyparrot',
+ 'url': 'https://example.test/parrot.gif',
+ },
+ {
+ 'shortcode': 'buzzbee',
+ 'url': 'https://relay.example/media/buzzbee.png',
+ },
+ ]);
+ expect(find.byType(EmojiPickerSheet), findsNothing);
+
+ final externalHeaders = await _sendNativeEmojiPickerCall(
+ tester,
+ 'mediaHeaders',
+ 'https://example.test/parrot.gif',
+ );
+ expect(externalHeaders, {});
+ final relayHeaders = await _sendNativeEmojiPickerCall(
+ tester,
+ 'mediaHeaders',
+ 'https://relay.example/media/buzzbee.png',
+ );
+ expect(
+ (relayHeaders as Map)['Authorization'],
+ startsWith('Nostr '),
+ );
+
+ await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4);
+ await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}');
+ await _sendNativeEmojiPickerCall(tester, 'dismissed');
+ expect(selected, ['\u{1F525}']);
+ expect(dismissals, 1);
+ expect(prefs.getInt('buzz.emoji-picker.skin-tone.v1'), 4);
+ } finally {
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ });
+
+ testWidgets('shows cancellable feedback while loading the custom palette', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ final palette = Completer>();
+ MethodCall? presentation;
+ _setMockNativeEmojiPickerHandler((call) async {
+ presentation = call;
+ return true;
+ });
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ customEmojiPaletteProvider.overrideWith(
+ () => _FakeCustomEmojiPaletteNotifier(palette.future),
+ ),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () =>
+ showEmojiPicker(context: context, onSelect: (_) {}),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pump();
+ expect(presentation, isNull);
+ expect(
+ find.byKey(const Key('ios-emoji-picker-palette-loading')),
+ findsOneWidget,
+ );
+ expect(find.text('Loading emoji…'), findsOneWidget);
+
+ palette.complete(_customEmoji);
+ await tester.pumpAndSettle();
+ expect(
+ find.byKey(const Key('ios-emoji-picker-palette-loading')),
+ findsNothing,
+ );
+ expect(presentation?.method, 'present');
+ } finally {
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ });
+
+ testWidgets('cancels palette loading without presenting native picker', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ final palette = Completer>();
+ var presents = 0;
+ var dismissals = 0;
+ _setMockNativeEmojiPickerHandler((call) async {
+ if (call.method == 'present') presents += 1;
+ return true;
+ });
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ customEmojiPaletteProvider.overrideWith(
+ () => _FakeCustomEmojiPaletteNotifier(palette.future),
+ ),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () => showEmojiPicker(
+ context: context,
+ onSelect: (_) {},
+ onDismiss: () => dismissals += 1,
+ ),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ expect(
+ find.byKey(const Key('ios-emoji-picker-palette-loading')),
+ findsOneWidget,
+ );
+
+ await tester.tapAt(const Offset(20, 20));
+ await tester.pumpAndSettle();
+ expect(dismissals, 1);
+ expect(presents, 0);
+
+ palette.complete(_customEmoji);
+ await tester.pumpAndSettle();
+ expect(presents, 0);
+ expect(dismissals, 1);
+ } finally {
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ });
+
+ testWidgets('falls back to the Flutter picker when native cannot present', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ _setMockNativeEmojiPickerHandler((_) async => false);
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
+ customEmojiPaletteProvider.overrideWith(
+ () =>
+ _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)),
+ ),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () =>
+ showEmojiPicker(context: context, onSelect: (_) {}),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pumpAndSettle();
+ expect(find.byType(EmojiPickerSheet), findsOneWidget);
+ } finally {
+ await tester.pumpWidget(const SizedBox.shrink());
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ });
+
+ testWidgets(
+ 'a palette load failure falls back to the Flutter picker exactly once',
+ (tester) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ var presents = 0;
+ _setMockNativeEmojiPickerHandler((_) async {
+ presents += 1;
+ return true;
+ });
+ var dismissals = 0;
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
+ customEmojiPaletteProvider.overrideWith(
+ () => _FakeCustomEmojiPaletteNotifier(
+ Future.error(StateError('palette unavailable')),
+ ),
+ ),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () => showEmojiPicker(
+ context: context,
+ onSelect: (_) {},
+ onDismiss: () => dismissals += 1,
+ ),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pumpAndSettle();
+
+ // The native sheet is never presented on a palette error; the
+ // Flutter picker takes over so the composer's open state is not
+ // stranded.
+ expect(presents, 0);
+ expect(find.byType(EmojiPickerSheet), findsOneWidget);
+
+ // Dismissing the fallback runs onDismiss exactly once.
+ await tester.tapAt(const Offset(20, 20));
+ await tester.pumpAndSettle();
+ expect(dismissals, 1);
+ } finally {
+ await tester.pumpWidget(const SizedBox.shrink());
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ },
+ );
+
+ testWidgets('a reentrant open cannot steal the live sheet callbacks', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final prefs = await _prefs();
+ var presents = 0;
+ _setMockNativeEmojiPickerHandler((call) async {
+ if (call.method == 'present') presents += 1;
+ return true;
+ });
+
+ final firstSelected = [];
+ final secondSelected = [];
+ var firstDismissals = 0;
+ var secondDismissals = 0;
+
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ customEmojiPaletteProvider.overrideWith(
+ () =>
+ _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)),
+ ),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => Column(
+ children: [
+ FilledButton(
+ onPressed: () => showEmojiPicker(
+ context: context,
+ onSelect: firstSelected.add,
+ onDismiss: () => firstDismissals += 1,
+ ),
+ child: const Text('Open first'),
+ ),
+ FilledButton(
+ onPressed: () => showEmojiPicker(
+ context: context,
+ onSelect: secondSelected.add,
+ onDismiss: () => secondDismissals += 1,
+ ),
+ child: const Text('Open second'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open first'));
+ await tester.pumpAndSettle();
+ expect(presents, 1);
+
+ // A second open while the first sheet is live is rejected: it neither
+ // presents again nor replaces the live sheet's method-call handler,
+ // and its independent lifecycle is completed immediately.
+ await tester.tap(find.text('Open second'));
+ await tester.pumpAndSettle();
+ expect(presents, 1);
+ expect(secondDismissals, 1);
+
+ // Native events still reach the original owner, and only it.
+ await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}');
+ await _sendNativeEmojiPickerCall(tester, 'dismissed');
+ expect(firstSelected, ['\u{1F525}']);
+ expect(secondSelected, isEmpty);
+ expect(firstDismissals, 1);
+ expect(secondDismissals, 1);
+ } finally {
+ _setMockNativeEmojiPickerHandler(null);
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ }
+ });
+ });
+
group('recent emoji ranking', () {
test('promotes by use count, breaking ties on recency', () {
var entries = [];
diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart
index 842a97211b..a8c44e3492 100644
--- a/mobile/test/features/channels/message_actions_test.dart
+++ b/mobile/test/features/channels/message_actions_test.dart
@@ -1,10 +1,15 @@
+import 'dart:ui' as ui;
+
+import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/message_actions.dart';
+import 'package:buzz/features/channels/message_long_press_region.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -180,8 +185,928 @@ Future _pumpImageSheet(
await tester.pumpAndSettle();
}
+Future _testMessageSnapshot() async {
+ final recorder = ui.PictureRecorder();
+ final canvas = ui.Canvas(recorder);
+ canvas.drawRect(
+ const Rect.fromLTWH(0, 0, 300, 72),
+ ui.Paint()..color = const Color(0xffeeeeee),
+ );
+ return recorder.endRecording().toImage(300, 72);
+}
+
+class _MessageActionsPopoverHarness {
+ final ProviderContainer container;
+ final ValueNotifier sourceHidden;
+
+ const _MessageActionsPopoverHarness({
+ required this.container,
+ required this.sourceHidden,
+ });
+}
+
+Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover(
+ WidgetTester tester, {
+ required TimelineMessage message,
+ required SharedPreferences prefs,
+ ReadStateNotifier Function()? readStateOverride,
+ bool canManageMessage = false,
+ List? allMessages,
+ ReminderService? reminderService,
+ bool disableAnimations = false,
+ EdgeInsets viewInsets = EdgeInsets.zero,
+ TextScaler textScaler = TextScaler.noScaling,
+ Future Function()? captureAnchorSnapshot,
+ FocusNode? composerFocusNode,
+ bool composerInitiallyFocused = false,
+ bool launcherOnNestedRoute = false,
+ ChannelActions Function(Ref ref)? createChannelActions,
+ Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72),
+}) async {
+ final sourceHidden = ValueNotifier(false);
+
+ Widget launcherPage() => Scaffold(
+ key: const ValueKey('message-actions-underlying-page'),
+ body: Consumer(
+ builder: (context, ref, _) => Column(
+ children: [
+ if (composerFocusNode != null)
+ TextField(focusNode: composerFocusNode),
+ TextButton(
+ key: const ValueKey('open-message-actions-popover'),
+ onPressed: () => showMessageActions(
+ context: context,
+ ref: ref,
+ message: message,
+ channelId: _channelId,
+ canManageMessage: canManageMessage,
+ allMessages: allMessages,
+ currentPubkey: 'self',
+ isMember: true,
+ anchorRect: anchorRect,
+ captureAnchorSnapshot:
+ captureAnchorSnapshot ?? _testMessageSnapshot,
+ onPopoverPreviewVisibilityChanged: (visible) =>
+ sourceHidden.value = visible,
+ onPopoverDismissed: () => sourceHidden.value = false,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: composerFocusNode?.requestFocus,
+ ),
+ child: const Text('open message actions'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ readStateProvider.overrideWith(
+ readStateOverride ??
+ () => _FakeReadStateNotifier(
+ _readState(const {_channelId: 100000}),
+ ),
+ ),
+ reminderServiceProvider.overrideWithValue(reminderService),
+ if (createChannelActions != null)
+ channelActionsProvider.overrideWith(createChannelActions),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ builder: (context, child) => MediaQuery(
+ data: MediaQuery.of(context).copyWith(
+ disableAnimations: disableAnimations,
+ viewInsets: viewInsets,
+ textScaler: textScaler,
+ ),
+ child: child!,
+ ),
+ home: launcherOnNestedRoute
+ ? Builder(
+ builder: (context) => Scaffold(
+ key: const ValueKey('message-actions-root-page'),
+ body: TextButton(
+ key: const ValueKey('push-message-actions-launcher'),
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => launcherPage()),
+ ),
+ child: const Text('push launcher'),
+ ),
+ ),
+ )
+ : launcherPage(),
+ ),
+ ),
+ );
+ if (launcherOnNestedRoute) {
+ await tester.tap(
+ find.byKey(const ValueKey('push-message-actions-launcher')),
+ );
+ await tester.pumpAndSettle();
+ }
+ if (composerInitiallyFocused) {
+ composerFocusNode!.requestFocus();
+ await tester.pump();
+ }
+ await tester.tap(find.byKey(const ValueKey('open-message-actions-popover')));
+ await tester.pumpAndSettle();
+ final container = ProviderScope.containerOf(
+ tester.element(find.byKey(const ValueKey('open-message-actions-popover'))),
+ );
+ return _MessageActionsPopoverHarness(
+ container: container,
+ sourceHidden: sourceHidden,
+ );
+}
+
+Future _dismissMessageActionsPopover(WidgetTester tester) async {
+ Navigator.of(
+ tester.element(find.byKey(const ValueKey('message-action-surface'))),
+ ).pop();
+ await tester.pumpAndSettle();
+}
+
+class _FakeChannelActions extends ChannelActions {
+ final reactions = <({String eventId, String emoji})>[];
+
+ _FakeChannelActions(Ref ref)
+ : super(
+ ref: ref,
+ session: ref.read(relaySessionProvider.notifier),
+ signedEventRelay: SignedEventRelay(
+ session: ref.read(relaySessionProvider.notifier),
+ nsec: null,
+ ),
+ currentPubkey: 'self',
+ );
+
+ @override
+ Future addReaction(String eventId, String emoji) async {
+ reactions.add((eventId: eventId, emoji: emoji));
+ }
+}
+
void main() {
+ testWidgets(
+ 'message long press keeps taps and scrolling while repeated holds win',
+ (tester) async {
+ var parentTaps = 0;
+ var nestedTaps = 0;
+ var longPresses = 0;
+ final scrollController = ScrollController();
+ addTearDown(scrollController.dispose);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SingleChildScrollView(
+ controller: scrollController,
+ child: Column(
+ children: [
+ Material(
+ child: MessageLongPressInkWell(
+ key: const ValueKey('parent-gesture-target'),
+ onTap: () => parentTaps += 1,
+ onLongPress: (_) => longPresses += 1,
+ child: const SizedBox(height: 80, width: 300),
+ ),
+ ),
+ Material(
+ child: MessageLongPressInkWell(
+ onLongPress: (_) => longPresses += 1,
+ child: GestureDetector(
+ key: const ValueKey('nested-gesture-target'),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => nestedTaps += 1,
+ child: const SizedBox(height: 80, width: 300),
+ ),
+ ),
+ ),
+ const SizedBox(height: 900),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(const ValueKey('parent-gesture-target')));
+ await tester.tap(find.byKey(const ValueKey('nested-gesture-target')));
+ await tester.pump();
+ expect(parentTaps, 1);
+ expect(nestedTaps, 1);
+
+ for (var index = 0; index < 5; index++) {
+ await tester.longPress(
+ find.byKey(const ValueKey('nested-gesture-target')),
+ );
+ await tester.pump();
+ expect(longPresses, index + 1);
+ expect(nestedTaps, 1);
+ }
+
+ final drag = await tester.startGesture(
+ tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))),
+ );
+ await drag.moveBy(const Offset(0, -30));
+ await tester.pump();
+ await drag.moveBy(const Offset(0, -70));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 600));
+ await drag.up();
+ await tester.pumpAndSettle();
+
+ expect(longPresses, 5);
+ expect(scrollController.offset, greaterThan(0));
+ },
+ );
+
+ testWidgets('iOS message long press recognizes at 200 ms', (tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ try {
+ var longPresses = 0;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Material(
+ child: MessageLongPressInkWell(
+ key: const ValueKey('ios-long-press-target'),
+ onLongPress: (_) => longPresses += 1,
+ child: const SizedBox(width: 240, height: 80),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final gesture = await tester.startGesture(
+ tester.getCenter(find.byKey(const ValueKey('ios-long-press-target'))),
+ );
+ await tester.pump(const Duration(milliseconds: 199));
+ expect(longPresses, 0);
+ await tester.pump(const Duration(milliseconds: 2));
+ expect(longPresses, 1);
+ await gesture.up();
+ } finally {
+ debugDefaultTargetPlatformOverride = null;
+ }
+ });
+
+ testWidgets(
+ 'message long press captures the content inside the ink surface',
+ (tester) async {
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetDevicePixelRatio);
+ MessageLongPressDetails? longPressDetails;
+ ui.Image? snapshot;
+ addTearDown(() => snapshot?.dispose());
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Material(
+ child: MessageLongPressInkWell(
+ key: const ValueKey('snapshot-gesture-target'),
+ onLongPressDetails: (details) => longPressDetails = details,
+ child: const SizedBox(width: 240, height: 80),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.longPress(
+ find.byKey(const ValueKey('snapshot-gesture-target')),
+ );
+ expect(longPressDetails, isNotNull);
+
+ final capture = longPressDetails!.captureSnapshot();
+ await tester.pump();
+ snapshot = await capture;
+
+ expect(snapshot.width, 240);
+ expect(snapshot.height, 80);
+ },
+ );
+
+ testWidgets('message snapshot bounds very tall raster dimensions', (
+ tester,
+ ) async {
+ tester.view.devicePixelRatio = 1;
+ tester.view.physicalSize = const Size(800, 5000);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ addTearDown(tester.view.resetPhysicalSize);
+ MessageLongPressDetails? longPressDetails;
+ ui.Image? snapshot;
+ addTearDown(() => snapshot?.dispose());
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Material(
+ child: MessageLongPressInkWell(
+ key: const ValueKey('tall-snapshot-gesture-target'),
+ onLongPressDetails: (details) => longPressDetails = details,
+ child: const SizedBox(width: 240, height: 4096),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.longPress(
+ find.byKey(const ValueKey('tall-snapshot-gesture-target')),
+ );
+ expect(longPressDetails, isNotNull);
+
+ final capture = longPressDetails!.captureSnapshot();
+ await tester.pump();
+ snapshot = await capture;
+
+ expect(snapshot.width, 120);
+ expect(snapshot.height, 2048);
+ });
+
+ testWidgets('message snapshot can exclude attached reaction content', (
+ tester,
+ ) async {
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetDevicePixelRatio);
+ final snapshotKey = GlobalKey();
+ MessageLongPressDetails? longPressDetails;
+ ui.Image? snapshot;
+ addTearDown(() => snapshot?.dispose());
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Material(
+ child: MessageLongPressInkWell(
+ key: const ValueKey('separate-snapshot-gesture-target'),
+ snapshotKey: snapshotKey,
+ onLongPressDetails: (details) => longPressDetails = details,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ RepaintBoundary(
+ key: snapshotKey,
+ child: const SizedBox(width: 240, height: 80),
+ ),
+ Listener(
+ key: const ValueKey('attached-reactions'),
+ behavior: HitTestBehavior.opaque,
+ child: const SizedBox(width: 240, height: 32),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.longPress(find.byKey(const ValueKey('attached-reactions')));
+ expect(longPressDetails, isNotNull);
+ expect(longPressDetails!.anchorRect.height, 80);
+
+ final capture = longPressDetails!.captureSnapshot();
+ await tester.pump();
+ snapshot = await capture;
+
+ expect(snapshot.width, 240);
+ expect(snapshot.height, 80);
+ });
+
group('showMessageActions', () {
+ testWidgets('composes the tray, lifted preview, and compact actions', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ final harness = await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ allMessages: [_message()],
+ reminderService: _stubReminderService(),
+ );
+
+ expect(harness.sourceHidden.value, isTrue);
+ expect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-surface')),
+ findsOneWidget,
+ );
+ expect(find.byType(BottomSheet), findsNothing);
+ expect(find.text('Reply'), findsOneWidget);
+ expect(find.text('Copy link'), findsOneWidget);
+ expect(find.text('Remind me'), findsOneWidget);
+ expect(find.text('Follow thread'), findsOneWidget);
+
+ final trayRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ );
+ final previewRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-preview')),
+ );
+ final actionRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-surface')),
+ );
+ final trayMaterial = tester.widget(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ );
+ final actionMaterial = tester.widget(
+ find.byKey(const ValueKey('message-action-surface')),
+ );
+ expect(previewRect.top, greaterThan(trayRect.bottom));
+ expect(actionRect.top, greaterThan(previewRect.bottom));
+ expect(previewRect.left, trayRect.left);
+ expect(actionRect.left, trayRect.left);
+ expect(actionRect.width, 288);
+ expect(actionMaterial.color, trayMaterial.color);
+
+ await _dismissMessageActionsPopover(tester);
+ expect(harness.sourceHidden.value, isFalse);
+ });
+
+ for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
+ testWidgets(
+ '${platform.name} composition keeps the action menu near the safe bottom',
+ (tester) async {
+ debugDefaultTargetPlatformOverride = platform;
+ try {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ allMessages: [_message()],
+ reminderService: _stubReminderService(),
+ );
+
+ final actionRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-surface')),
+ );
+ final logicalHeight =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1));
+
+ await _dismissMessageActionsPopover(tester);
+ } finally {
+ debugDefaultTargetPlatformOverride = null;
+ }
+ },
+ );
+ }
+
+ testWidgets('keeps the action menu above the software keyboard', (
+ tester,
+ ) async {
+ const keyboardInset = 300.0;
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ allMessages: [_message()],
+ reminderService: _stubReminderService(),
+ viewInsets: const EdgeInsets.only(bottom: keyboardInset),
+ );
+
+ final actionRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-surface')),
+ );
+ final logicalHeight =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(
+ actionRect.bottom,
+ closeTo(logicalHeight - keyboardInset - Grid.xxs, 0.1),
+ );
+
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('collapses fixed sections in a short keyboard viewport', (
+ tester,
+ ) async {
+ tester.view.devicePixelRatio = 1;
+ tester.view.physicalSize = const Size(800, 220);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ addTearDown(tester.view.resetPhysicalSize);
+ const keyboardInset = 100.0;
+ final prefs = await _mockPrefs();
+ final harness = await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ allMessages: [_message()],
+ reminderService: _stubReminderService(),
+ viewInsets: const EdgeInsets.only(bottom: keyboardInset),
+ );
+
+ expect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ findsNothing,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsNothing,
+ );
+ expect(harness.sourceHidden.value, isFalse);
+ final actionRect = tester.getRect(
+ find.byKey(const ValueKey('message-action-surface')),
+ );
+ expect(actionRect.top, greaterThanOrEqualTo(Grid.xxs));
+ expect(
+ actionRect.bottom,
+ lessThanOrEqualTo(220 - keyboardInset - Grid.xxs),
+ );
+ expect(tester.takeException(), isNull);
+
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('keeps tall message previews within the visible viewport', (
+ tester,
+ ) async {
+ const keyboardInset = 300.0;
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(pubkey: 'self'),
+ prefs: prefs,
+ canManageMessage: true,
+ allMessages: [_message(pubkey: 'self')],
+ reminderService: _stubReminderService(),
+ viewInsets: const EdgeInsets.only(bottom: keyboardInset),
+ anchorRect: const Rect.fromLTWH(32, 40, 300, 2000),
+ );
+
+ final logicalHeight =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ final visibleBottom = logicalHeight - keyboardInset - Grid.xxs;
+ expect(
+ tester
+ .getRect(find.byKey(const ValueKey('message-action-preview')))
+ .top,
+ greaterThanOrEqualTo(Grid.xxs),
+ );
+ expect(
+ tester
+ .getRect(find.byKey(const ValueKey('message-action-surface')))
+ .bottom,
+ lessThanOrEqualTo(visibleBottom),
+ );
+
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('restores composer focus only after a dismissed popover', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ final prefs = await _mockPrefs();
+
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ composerFocusNode: focusNode,
+ composerInitiallyFocused: true,
+ );
+
+ expect(focusNode.hasFocus, isFalse);
+ await _dismissMessageActionsPopover(tester);
+ expect(focusNode.hasFocus, isTrue);
+ });
+
+ testWidgets('leaves an initially unfocused composer unfocused', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ final prefs = await _mockPrefs();
+
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ composerFocusNode: focusNode,
+ );
+
+ expect(focusNode.hasFocus, isFalse);
+ await _dismissMessageActionsPopover(tester);
+ expect(focusNode.hasFocus, isFalse);
+ });
+
+ testWidgets('does not restore composer focus after opening reactions', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ final prefs = await _mockPrefs();
+
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ composerFocusNode: focusNode,
+ composerInitiallyFocused: true,
+ );
+
+ await tester.tap(find.byKey(const ValueKey('quick-reaction-more')));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ expect(focusNode.hasFocus, isFalse);
+ });
+
+ testWidgets('does not restore composer focus after selecting an action', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ final prefs = await _mockPrefs();
+
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(rootId: 'root-9'),
+ prefs: prefs,
+ composerFocusNode: focusNode,
+ composerInitiallyFocused: true,
+ );
+
+ await tester.tap(
+ find.byKey(const ValueKey('message-action-followThread')),
+ );
+ await tester.pumpAndSettle();
+ expect(focusNode.hasFocus, isFalse);
+ });
+
+ testWidgets('runs an action after dismissal and can reopen', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ final harness = await _pumpMessageActionsPopover(
+ tester,
+ message: _message(rootId: 'root-9'),
+ prefs: prefs,
+ );
+
+ await tester.tap(
+ find.byKey(const ValueKey('message-action-followThread')),
+ );
+ await tester.pumpAndSettle();
+
+ expect(harness.sourceHidden.value, isFalse);
+ expect(harness.container.read(threadFollowsProvider).followedRootIds, {
+ 'root-9',
+ });
+
+ await tester.tap(
+ find.byKey(const ValueKey('open-message-actions-popover')),
+ );
+ await tester.pumpAndSettle();
+ expect(
+ find.byKey(const ValueKey('message-action-surface')),
+ findsOneWidget,
+ );
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('ignores repeat action taps once dismissal starts', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ final harness = await _pumpMessageActionsPopover(
+ tester,
+ message: _message(rootId: 'root-9'),
+ prefs: prefs,
+ launcherOnNestedRoute: true,
+ );
+ final action = find.byKey(const ValueKey('message-action-followThread'));
+ final actionWidget = tester.widget(action);
+
+ actionWidget.onTap!.call();
+ actionWidget.onTap!.call();
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const ValueKey('message-actions-underlying-page')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-actions-root-page')),
+ findsNothing,
+ );
+ expect(harness.container.read(threadFollowsProvider).followedRootIds, {
+ 'root-9',
+ });
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ignores repeat backdrop taps once dismissal starts', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ launcherOnNestedRoute: true,
+ );
+ final backdrop = tester.widget(
+ find.byKey(const ValueKey('message-actions-backdrop')),
+ );
+
+ backdrop.onTap!.call();
+ backdrop.onTap!.call();
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const ValueKey('message-actions-underlying-page')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-actions-root-page')),
+ findsNothing,
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ignores repeat quick reactions once dismissal starts', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ late _FakeChannelActions actions;
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ launcherOnNestedRoute: true,
+ createChannelActions: (ref) => actions = _FakeChannelActions(ref),
+ );
+ final reaction = find.byKey(const ValueKey('quick-reaction-\u{1F44D}'));
+ final detector = tester.widget(
+ find.descendant(of: reaction, matching: find.byType(GestureDetector)),
+ );
+
+ detector.onTap!.call();
+ detector.onTap!.call();
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const ValueKey('message-actions-underlying-page')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-actions-root-page')),
+ findsNothing,
+ );
+ expect(actions.reactions, [(eventId: 'msg-1', emoji: '\u{1F44D}')]);
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('fallback action rows grow with accessibility text', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(rootId: 'root-9'),
+ prefs: prefs,
+ textScaler: const TextScaler.linear(3),
+ );
+
+ final rowFinder = find.byKey(
+ const ValueKey('message-action-followThread'),
+ );
+ expect(tester.getSize(rowFinder).height, greaterThan(48));
+ expect(tester.takeException(), isNull);
+
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('orders primary, utility, and destructive action groups', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ canManageMessage: true,
+ allMessages: [_message()],
+ reminderService: _stubReminderService(),
+ );
+
+ const actionIds = [
+ 'reply',
+ 'markUnread',
+ 'edit',
+ 'copyText',
+ 'copyLink',
+ 'remind',
+ 'followThread',
+ 'delete',
+ ];
+ final actionTops = [
+ for (final actionId in actionIds)
+ tester
+ .getTopLeft(find.byKey(ValueKey('message-action-$actionId')))
+ .dy,
+ ];
+ expect(actionTops, orderedEquals([...actionTops]..sort()));
+ expect(
+ find.byKey(const ValueKey('message-action-divider-utility')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-divider-destructive')),
+ findsOneWidget,
+ );
+
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('reduced motion presents the complete surface immediately', (
+ tester,
+ ) async {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ disableAnimations: true,
+ );
+
+ expect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-surface')),
+ findsOneWidget,
+ );
+ await _dismissMessageActionsPopover(tester);
+ });
+
+ testWidgets('keeps the snapshot alive through the reverse transition', (
+ tester,
+ ) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ ui.Image? snapshot;
+ try {
+ final prefs = await _mockPrefs();
+ await _pumpMessageActionsPopover(
+ tester,
+ message: _message(),
+ prefs: prefs,
+ captureAnchorSnapshot: () async {
+ snapshot = await _testMessageSnapshot();
+ return snapshot!;
+ },
+ );
+
+ final image = snapshot!;
+ expect(image.debugDisposed, isFalse);
+ Navigator.of(
+ tester.element(find.byKey(const ValueKey('message-action-surface'))),
+ ).pop();
+ await tester.pump();
+
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsOneWidget,
+ );
+ expect(image.debugDisposed, isFalse);
+ await tester.pump(const Duration(milliseconds: 110));
+ expect(image.debugDisposed, isFalse);
+
+ await tester.pumpAndSettle();
+ expect(image.debugDisposed, isTrue);
+ expect(tester.takeException(), isNull);
+ } finally {
+ final image = snapshot;
+ if (image != null && !image.debugDisposed) image.dispose();
+ debugDefaultTargetPlatformOverride = null;
+ }
+ });
+
testWidgets('shows parity actions for a regular message', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart
index 34b9f4cfb1..e66e9bb3da 100644
--- a/mobile/test/features/pairing/pairing_page_test.dart
+++ b/mobile/test/features/pairing/pairing_page_test.dart
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:local_auth/local_auth.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/community/community.dart';
@@ -65,7 +66,7 @@ void main() {
expect(overlay.value.statusBarColor, Colors.transparent);
});
- testWidgets('uses light status-bar icons for dark-theme SAS verification', (
+ testWidgets('uses the onboarding surface for dark-theme SAS verification', (
tester,
) async {
await tester.pumpWidget(
@@ -81,9 +82,61 @@ void main() {
find.byKey(const Key('pairing-sas-system-overlay')),
);
- expect(overlay.value.statusBarIconBrightness, Brightness.light);
+ expect(overlay.value.statusBarIconBrightness, Brightness.dark);
expect(overlay.value.statusBarColor, Colors.transparent);
- expect(find.text('Verify Security Code'), findsOneWidget);
+ final background = tester.widget(
+ find.byKey(const Key('pairing-onboarding-background')),
+ );
+ final backgroundDecoration = background.decoration as BoxDecoration;
+ final backgroundGradient =
+ backgroundDecoration.gradient! as LinearGradient;
+ expect(backgroundGradient.colors, const [
+ Color(0xFFD7D72E),
+ Color(0xFFD7E7F6),
+ ]);
+ expect(
+ tester.widget(find.byType(Scaffold)).backgroundColor,
+ Colors.transparent,
+ );
+ expect(find.text('Confirm desktop code'), findsOneWidget);
+ expect(
+ find.text(
+ 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
+ ),
+ findsOneWidget,
+ );
+ expect(find.text('Does your desktop app show this code?'), findsNothing);
+ });
+
+ testWidgets('uses Cancel as the only visible SAS exit', (tester) async {
+ final notifier = _ConfirmingSasPairingNotifier();
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [pairingProvider.overrideWith(() => notifier)],
+ child: MaterialApp(
+ theme: AppTheme.dark(),
+ home: const PairingPage(addingCommunity: true),
+ ),
+ ),
+ );
+
+ expect(find.byType(AppBar), findsNothing);
+ expect(find.text('Add Community'), findsNothing);
+ expect(find.byIcon(LucideIcons.arrowLeft), findsNothing);
+ expect(find.byKey(const Key('pairing-pop-scope')), findsOneWidget);
+
+ await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
+ expect(notifier.denied, isTrue);
+ });
+
+ testWidgets('keeps the add-community header outside SAS', (tester) async {
+ await tester.pumpWidget(
+ WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)),
+ );
+
+ expect(find.byType(AppBar), findsOneWidget);
+ expect(find.text('Add Community'), findsOneWidget);
+ expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget);
});
testWidgets('reveals pairing code field and connect action', (
@@ -326,7 +379,7 @@ void main() {
);
});
- testWidgets('recovery SAS warns about permanent desktop access', (
+ testWidgets('recovery SAS puts permanent desktop access in the subtitle', (
tester,
) async {
await tester.pumpWidget(
@@ -342,11 +395,220 @@ void main() {
expect(find.textContaining('full Buzz identity'), findsOneWidget);
expect(find.textContaining('permanent access'), findsOneWidget);
- expect(find.text('Codes Match'), findsOneWidget);
+ expect(find.textContaining('started this recovery'), findsOneWidget);
+ expect(find.text('Codes match'), findsOneWidget);
+ });
+
+ testWidgets('matches the onboarding visual system and SAS action layout', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
+ ],
+ child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
+ ),
+ );
+
+ expect(find.byIcon(LucideIcons.shieldCheck), findsNothing);
+ expect(find.text('Confirm desktop code'), findsOneWidget);
+ expect(
+ find.text(
+ 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
+ ),
+ findsOneWidget,
+ );
+ expect(find.text('Does your desktop app show this code?'), findsNothing);
+
+ final digitFinders = [
+ for (var index = 1; index <= 6; index++)
+ find.byKey(Key('pairing-sas-code-digit-$index')),
+ ];
+ for (final digitFinder in digitFinders) {
+ expect(tester.getSize(digitFinder).width, 54);
+ expect(
+ tester.widget(digitFinder).padding,
+ const EdgeInsets.symmetric(vertical: Grid.xs),
+ );
+ }
+
+ const onboardingInk = Color(0xFF111111);
+ const onboardingMutedInk = Color(0xB3111111);
+ const onboardingCtaLabel = Color(0xFFD7E6F0);
+ final theme = AppTheme.dark();
+ final protectionTile = tester.widget(
+ find.byKey(const Key('protect-sensitive-actions-checkbox')),
+ );
+ expect(protectionTile.activeColor, onboardingInk);
+ expect(protectionTile.checkColor, onboardingCtaLabel);
+ expect(protectionTile.side?.color, onboardingInk);
+ expect((protectionTile.title as Text).style?.color, onboardingInk);
+ expect(
+ (protectionTile.subtitle as Text).style?.color,
+ onboardingMutedInk,
+ );
+ final firstDigitContainer = tester.widget(digitFinders.first);
+ final firstDigitDecoration =
+ firstDigitContainer.decoration! as BoxDecoration;
+ expect(firstDigitDecoration.color, Colors.white.withValues(alpha: 0.7));
+ expect(
+ (firstDigitDecoration.border! as Border).top.color,
+ theme.colorScheme.primary.withValues(alpha: 0.15),
+ );
+ final firstDigitText = tester.widget(
+ find.descendant(of: digitFinders.first, matching: find.text('1')),
+ );
+ expect(firstDigitText.style?.fontFamily, 'Inter');
+ expect(
+ firstDigitText.style?.fontSize,
+ theme.textTheme.displaySmall?.fontSize,
+ );
+ expect(firstDigitText.style?.fontSize, greaterThanOrEqualTo(36));
+ expect(firstDigitText.style?.fontWeight, FontWeight.w600);
+ expect(firstDigitText.style?.fontFeatures, isNull);
+ expect(firstDigitText.style?.color, onboardingInk);
+
+ final firstDigit = tester.getTopLeft(digitFinders[0]);
+ final secondDigit = tester.getTopLeft(digitFinders[1]);
+ final thirdDigit = tester.getTopLeft(digitFinders[2]);
+ final fourthDigit = tester.getTopLeft(digitFinders[3]);
+ expect(secondDigit.dx - firstDigit.dx, 60);
+ expect(fourthDigit.dx - thirdDigit.dx, 68);
+
+ final confirmFinder = find.widgetWithText(FilledButton, 'Codes match');
+ final cancelFinder = find.widgetWithText(TextButton, 'Cancel');
+ final confirmButton = tester.widget(confirmFinder);
+ final cancelButton = tester.widget(cancelFinder);
+ expect(
+ confirmButton.style?.backgroundColor?.resolve({}),
+ onboardingInk,
+ );
+ expect(
+ confirmButton.style?.foregroundColor?.resolve({}),
+ onboardingCtaLabel,
+ );
+ expect(
+ confirmButton.style?.shape?.resolve({}),
+ isA(),
+ );
+ expect(
+ cancelButton.style?.backgroundColor?.resolve({}),
+ onboardingInk.withValues(alpha: 0.1),
+ );
+ expect(
+ cancelButton.style?.foregroundColor?.resolve({}),
+ onboardingInk,
+ );
+ expect(
+ cancelButton.style?.shape?.resolve({}),
+ isA(),
+ );
+ final confirmTopLeft = tester.getTopLeft(confirmFinder);
+ final cancelTopLeft = tester.getTopLeft(cancelFinder);
+ final scaffoldWidth = tester.getSize(find.byType(Scaffold)).width;
+ expect(confirmTopLeft.dy, lessThan(cancelTopLeft.dy));
+ expect(confirmTopLeft.dx, cancelTopLeft.dx);
+ expect(confirmTopLeft.dx, Grid.sm);
+ expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2);
+ expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2);
+ expect(tester.getSize(confirmFinder).height, 48);
+ expect(tester.getSize(cancelFinder).height, 48);
+ expect(
+ find.textContaining(
+ 'Only continue if you started this pairing from your desktop.',
+ ),
+ findsOneWidget,
+ );
+ expect(
+ tester.getBottomLeft(find.byType(Scaffold)).dy -
+ tester.getBottomLeft(cancelFinder).dy,
+ Grid.sm,
+ );
+ });
+
+ testWidgets('uses accessible SAS error contrast in both themes', (
+ tester,
+ ) async {
+ const errorMessage = 'Identity confirmation failed. Nothing transferred.';
+ const errorInk = Color(0xFF7A1025);
+ const gradientColors = [Color(0xFFD7D72E), Color(0xFFD7E7F6)];
+
+ for (final theme in [AppTheme.light(), AppTheme.dark()]) {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pairingProvider.overrideWith(
+ () => _ConfirmingSasPairingNotifier(errorMessage: errorMessage),
+ ),
+ ],
+ child: MaterialApp(theme: theme, home: const PairingPage()),
+ ),
+ );
+
+ final errorText = tester.widget(find.text(errorMessage));
+ expect(errorText.style?.color, errorInk);
+ for (final background in gradientColors) {
+ expect(
+ _contrastRatio(errorInk, background),
+ greaterThanOrEqualTo(4.5),
+ );
+ }
+ expect(tester.takeException(), isNull);
+ }
+ });
+
+ testWidgets('keeps SAS actions above the keyboard on small screens', (
+ tester,
+ ) async {
+ tester.view.devicePixelRatio = 1;
+ tester.view.physicalSize = const Size(360, 560);
+ tester.view.viewInsets = const FakeViewPadding(bottom: 200);
+ addTearDown(tester.view.reset);
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
+ ],
+ child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.byType(SingleChildScrollView), findsOneWidget);
+ final cancelFinder = find.widgetWithText(TextButton, 'Cancel');
+ expect(tester.getBottomLeft(cancelFinder).dy, 560 - 200 - Grid.sm);
+
+ await tester.drag(
+ find.byType(SingleChildScrollView),
+ const Offset(0, -100),
+ );
+ await tester.pump();
+ expect(tester.takeException(), isNull);
+ expect(find.text('Confirm desktop code'), findsOneWidget);
+ expect(find.textContaining('matches on both devices'), findsOneWidget);
+ expect(
+ find.textContaining('Buzz identity will transfer'),
+ findsOneWidget,
+ );
+ expect(find.text('Codes match'), findsOneWidget);
});
});
}
+double _contrastRatio(Color foreground, Color background) {
+ final foregroundLuminance = foreground.computeLuminance();
+ final backgroundLuminance = background.computeLuminance();
+ final lighter = foregroundLuminance > backgroundLuminance
+ ? foregroundLuminance
+ : backgroundLuminance;
+ final darker = foregroundLuminance > backgroundLuminance
+ ? backgroundLuminance
+ : foregroundLuminance;
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
Future _expandPairingCode(WidgetTester tester) async {
await tester.tap(find.text('Use pairing code'));
await tester.pumpAndSettle();
@@ -435,15 +697,21 @@ class _RecordingPairingNotifier extends Notifier